diff --git a/apps/utility-css/src/Services/PracticeLibrary.cs b/apps/utility-css/src/Services/PracticeLibrary.cs index 8712ec4e..fcb5aebd 100644 --- a/apps/utility-css/src/Services/PracticeLibrary.cs +++ b/apps/utility-css/src/Services/PracticeLibrary.cs @@ -16,6 +16,24 @@ public class PracticeLibrary private readonly Dictionary> _lineups = new(); private string _map = ""; + // What this server changed in a player's library itself -- a .save, a + // .delete -- and when, by client id. A fetch that went out before the + // change, or before the panel had it, answers with the library as it was; + // applied as-is it dropped the lineup somebody had just saved, or brought + // back the one they had just deleted. Kept is null for a removal. + private sealed class LocalChange + { + public DateTime At; + public LineupRecord? Kept; + } + + private readonly Dictionary> _local = new(); + + // How long a local change outranks the panel's answer once a fetch has + // started after it. A save is one round trip away from being on the panel; + // past this, a lineup missing from the answer is one the website removed. + private static readonly TimeSpan LocalHold = TimeSpan.FromMinutes(2); + public PracticeLibrary(UtilityApiClient api, ILogger logger) { _api = api; @@ -33,6 +51,7 @@ public void SetMap(string map) _map = map; _lineups.Clear(); + _local.Clear(); } public IReadOnlyList For(ulong steamId) @@ -57,6 +76,7 @@ public void Add(ulong steamId, LineupRecord lineup) lineups.RemoveAll(existing => existing.client_id == lineup.client_id); lineups.Add(lineup); + Touch(steamId, lineup.client_id, lineup); } public void Remove(ulong steamId, LineupRecord lineup) @@ -65,6 +85,92 @@ public void Remove(ulong steamId, LineupRecord lineup) { lineups.RemoveAll(existing => existing.client_id == lineup.client_id); } + + Touch(steamId, lineup.client_id, null); + } + + private void Touch(ulong steamId, string clientId, LineupRecord? kept) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + changes = new Dictionary(); + _local[steamId] = changes; + } + + changes[clientId] = new LocalChange { At = DateTime.UtcNow, Kept = kept }; + } + + // The panel's answer, with this server's own changes laid back over it + // while the answer cannot have seen them yet. A change is forgotten once + // an answer agrees with it, or once it is old enough that disagreeing + // means somebody else changed it since. + private List MergeLocal(ulong steamId, DateTime startedAt, List answer) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + return answer; + } + + DateTime now = DateTime.UtcNow; + var merged = new List(answer); + + foreach ((string clientId, LocalChange change) in changes.ToList()) + { + int index = merged.FindIndex(row => row.client_id == clientId); + + // Made after the request left: the answer cannot know about it. + bool newer = change.At >= startedAt; + bool holding = newer || now - change.At < LocalHold; + + if (change.Kept == null) + { + if (index < 0) + { + if (!newer) + { + changes.Remove(clientId); + } + } + else if (holding) + { + merged.RemoveAt(index); + } + else + { + changes.Remove(clientId); + } + + continue; + } + + if (index >= 0) + { + if (newer) + { + merged[index] = change.Kept; + } + else + { + changes.Remove(clientId); + } + + continue; + } + + // Not on the panel yet. One with no id never reached it at all -- + // it is waiting in the retry queue -- so it stays for as long as it + // stays in this server's hands. + if (holding || string.IsNullOrEmpty(change.Kept.id)) + { + merged.Add(change.Kept); + } + else + { + changes.Remove(clientId); + } + } + + return merged; } // A library row carries no flight path and no measured bloom, so neither @@ -103,6 +209,7 @@ public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action? done = null) { string map = _map; + DateTime startedAt = DateTime.UtcNow; _ = Task.Run(async () => { @@ -124,6 +231,8 @@ public void Refresh(ulong steamId, Action? done = null) return; } + lineups = MergeLocal(steamId, startedAt, lineups); + _lineups[steamId] = lineups; done?.Invoke(lineups.Count); }); diff --git a/apps/utility-css/src/Services/PracticeReplay.cs b/apps/utility-css/src/Services/PracticeReplay.cs index 0d5b412d..6e35af50 100644 --- a/apps/utility-css/src/Services/PracticeReplay.cs +++ b/apps/utility-css/src/Services/PracticeReplay.cs @@ -461,19 +461,12 @@ private void ShowMarkers(LineupRecord lineup) Vec3 stance = lineup.release.feet_position; Vec3 landing = lineup.detonation_position; + // No text on the stance or over the landing: the rings and their + // colour already say it, and words hung in the air above the smoke + // only got in the way of seeing it. The name lives at the aim point, + // where it tells several throws off one spot apart. Ring(stance, 18f, color, 1.5f); - Label( - new Vec3(stance.x, stance.y, stance.z + 12f), - $"STAND\n{lineup.name}", - color - ); - Ring(landing, 26f, color, 2f); - Label( - new Vec3(landing.x, landing.y, landing.z + 16f), - lineup.utility_type.ToUpperInvariant(), - color - ); // Where to look, placed along the recorded aim at the distance the // throw actually travelled, so it sits on the thing being aimed at @@ -494,8 +487,41 @@ private void ShowMarkers(LineupRecord lineup) eye.z + (float)(-Math.Sin(pitch)) * reach ); - Label(aim, "AIM", color); - Ring(aim, 10f, color, 1f); + // Sized by distance from the eye so it covers the same slice of + // the view at any range. A fixed 10-unit ring was fine far off but + // swallowed the screen on a short throw, with nothing in it to + // pinpoint; past ~220 units it is the same ring as before. + float away = new Vec3(aim.x - eye.x, aim.y - eye.y, aim.z - eye.z).Length(); + float radius = Math.Clamp(away * 0.045f, 0.25f, 10f); + float width = Math.Clamp(away * 0.0045f, 0.02f, 1f); + + Ring(aim, radius, color, width); + + // Shrinks with the ring up close so the name never covers the + // point it is naming. Unchanged from ~220 units out. + float labelScale = Math.Min(1f, radius / 10f); + Label( + new Vec3(aim.x, aim.y, aim.z + radius + 6f * labelScale), + lineup.name, + color, + 0.15f * labelScale + ); + + // The exact point, so there is something precise to cover. + float dot = Math.Max(radius * 0.08f, 0.05f); + + AddMarkerBeam( + new Vec3(aim.x - dot, aim.y, aim.z + 2f), + new Vec3(aim.x + dot, aim.y, aim.z + 2f), + color, + width * 1.6f + ); + AddMarkerBeam( + new Vec3(aim.x, aim.y - dot, aim.z + 2f), + new Vec3(aim.x, aim.y + dot, aim.z + 2f), + color, + width * 1.6f + ); } } @@ -530,7 +556,17 @@ private void Ring(Vec3 center, float radius, Color color, float width) } } - private void Label(Vec3 at, string text, Color color) + private void AddMarkerBeam(Vec3 start, Vec3 end, Color color, float width) + { + CEnvBeam? beam = CreateBeam(start, end, color, width); + + if (beam != null) + { + _markerBeams.Add(beam); + } + } + + private void Label(Vec3 at, string text, Color color, float unitsPerPx) { try { @@ -548,7 +584,7 @@ private void Label(Vec3 at, string text, Color color) label.FontSize = 60; label.FontName = "Arial Black"; label.Fullbright = true; - label.WorldUnitsPerPx = 0.15f; + label.WorldUnitsPerPx = unitsPerPx; label.Enabled = true; label.JustifyHorizontal = PointWorldTextJustifyHorizontal_t .POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER; diff --git a/apps/utility-css/src/UtilityPracticePlugin.cs b/apps/utility-css/src/UtilityPracticePlugin.cs index 068da6ef..02a88f9a 100644 --- a/apps/utility-css/src/UtilityPracticePlugin.cs +++ b/apps/utility-css/src/UtilityPracticePlugin.cs @@ -144,6 +144,13 @@ public override void Unload(bool hotReload) _playbook.Reset(); _drill.Reset(); _system.Reset(); + + // One last try at what the panel has not taken yet: it lives only in + // this instance's memory. Not waited for -- this is the game thread. + if (_api.QueuedCount > 0) + { + _ = Task.Run(() => _api.Drain()); + } } private void OnSecond() @@ -158,6 +165,33 @@ private void OnSecond() // A no-op once the map has answered; see MapCalloutsReporter.Report. _callouts.Report(_session.Map); DrainPendingMapLoad(); + DrainQueued(); + } + + // The retry queue only used to move when something else got through -- a + // later save, a scored throw, a map change -- so a panel that came back + // while nobody was saving left everything queued until the next one. + private const int DrainEverySeconds = 30; + + private int _drainTicks; + + private void DrainQueued() + { + if (++_drainTicks < DrainEverySeconds) + { + return; + } + + _drainTicks = 0; + + if (_api.QueuedCount == 0) + { + return; + } + + // Off the game thread like every other call to the panel; Drain lets + // only one pass run at a time, so a slow one is never stacked on. + _ = Task.Run(() => _api.Drain()); } // Nobody stays dead on a practice server. Rejoining while dead, falling off diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index ec385bbe..fb99d40b 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -144,10 +144,13 @@ private void SaveThrow(ulong steamId, string name) // becomes the loaded one and gets its markers straight away. PracticeState saved = _system.StateFor(steamId); + saved.Cleared = false; saved.Loaded = thrown; + saved.Practising = null; saved.Results.Clear(); saved.Results.Add(thrown); saved.Index = 0; + _replay.ApplyLibraryVisibility(player); // Deliberately not the full .load: the player is already standing on // the spot they just threw from, and teleporting them onto it would @@ -316,6 +319,11 @@ public void OnPrev(ICommandContext context) Step(context, -1); } + // sv_rethrow_last_grenade, which is what the word means everywhere else: + // the grenade goes again from where it was thrown and the player stays put, + // so they can stand off to the side and watch the line they cannot see + // while making it. .load, .last and .back are the commands that move you -- + // this one moving you as well is what left it with nothing of its own to do. [Command("rethrow", registerRaw: false, permission: "")] public void OnRethrow(ICommandContext context) { @@ -325,17 +333,66 @@ public void OnRethrow(ICommandContext context) { return; } - _logger.LogInformation("[nade-render] OnRethrow for {steam}", player.SteamID); - LineupRecord? loaded = _system.StateFor(player.SteamID).Loaded; + if (!_config.ReplayEnabled) + { + Reply(context, $" {ChatColors.Red}replay is disabled on this server"); + return; + } - if (loaded == null) + ulong steamId = player.SteamID; + + // The loaded lineup first, the player's own last throw second: after a + // .load the reference throw is the one worth seeing again, and with + // nothing loaded "rethrow" can only mean the grenade they just threw. + // Answering that one with "nothing loaded" is most of why this read as + // a command that did not work. + LineupRecord? lineup = _system.StateFor(steamId).Loaded ?? _recorder.LastThrow(steamId); + + if (lineup == null) + { + Reply( + context, + $" {ChatColors.Red}throw something first, or {ChatColors.Default}.load" + + $" {ChatColors.Red}a lineup" + ); + + return; + } + + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn == null || !pawn.IsValid) { - Reply(context, $" {ChatColors.Red}nothing loaded"); + Reply(context, $" {ChatColors.Red}you have to be alive to rethrow"); return; } - Apply(player, loaded); + // A lineup fitted to a demo has a path but no seed, so there is nothing + // to hand the engine. Said out loud rather than logged: a command that + // silently does nothing is the bug being fixed here. + if (!lineup.IsExactlyReplayable()) + { + Reply( + context, + $" {ChatColors.Yellow}{DrillUtility.Name(lineup)} {ChatColors.Grey}has no recorded" + + $" throw to replay -- {ChatColors.Default}.load{ChatColors.Grey} it and" + + $" throw it yourself" + ); + + return; + } + + _logger.LogInformation("[nade-render] OnRethrow for {steam}", steamId); + + // Forced: np_ghost_projectile decides whether .load and .next throw as + // they go, but a player who typed the word has already asked. + _replay.ThrowGhostProjectile(player, lineup, force: true); + + Reply( + context, + $" {ChatColors.Green}rethrown {ChatColors.Default}{DrillUtility.Name(lineup)}" + ); } [Command("last", registerRaw: false, permission: "")] @@ -398,6 +455,37 @@ public void OnCallouts(ICommandContext context) } } + // Back to the whole map after loading one lineup. The loaded lineup stays + // loaded -- .rethrow still goes back to it -- it just stops being the only + // thing drawn. + [Command("all", registerRaw: false, permission: "")] + public void OnAll(ICommandContext context) + { + IPlayer? player = context.Sender; + + if (player == null || !player.IsValid) + { + return; + } + + PracticeState state = _system.StateFor(player.SteamID); + + if (state.Practising == null) + { + Reply(context, $" {ChatColors.Grey}already showing every lineup"); + return; + } + + state.Practising = null; + _replay.ApplyLibraryVisibility(player); + + // The selection was narrowed to the one throw; drawn again on the next + // spot check with everything off the spot the player is standing on. + _standingIn.Remove(player.SteamID); + + Reply(context, $" {ChatColors.Green}showing every lineup"); + } + [Command("clear", registerRaw: false, permission: "")] public void OnClear(ICommandContext context) { @@ -410,21 +498,44 @@ public void OnClear(ICommandContext context) PracticeState state = _system.StateFor(player.SteamID); state.Loaded = null; + state.Practising = null; state.Results.Clear(); state.Index = -1; state.Bloom = false; + // Held until the next .load, .next, walk-on or save. Sweeping alone + // left the spot watcher free to redraw the spot under their feet on + // its next pass -- a quarter of a second, or the first time they + // glanced at another ring -- which is why .clear read as broken. + state.Cleared = true; + _replay.ApplyLibraryVisibility(player); + _replay.ClearGhosts(player.SteamID); - // Swept rather than cleared: ClearMarkers can only despawn what this - // instance still has a handle to, and anything a previous load left - // behind is exactly what makes .clear look like it did nothing. - // _standingIn is deliberately NOT reset -- the spot the player is - // stood on stays cleared until they step off it and back on, instead - // of redrawing itself a second later. - _replay.SweepMarkers(); + // What everybody else's .clear means, and what a player standing in + // their own smoke is asking for. The ping key does this on its own -- + // this is the same call, for anybody who types it. + int thrown = _replay.ClearThrownUtility(); + + // Their own selection only. The library layer is everyone's, and the + // sweep that used to be here took it -- and every other player's + // crosshair -- off the map for the whole server; the hold above hides + // it from this player alone. A previous load's litter is already + // swept when this instance loads. + _replay.ClearSelectionFor(player.SteamID); - Reply(context, $" {ChatColors.Green}cleared"); + // Safe to drop now the watcher is held off: nothing can match against + // it, so the first redraw after they ask again is decided by where + // they are then rather than by where they were when they cleared. + ForgetSpot(player.SteamID); + + Reply( + context, + thrown > 0 + ? $" {ChatColors.Green}cleared {ChatColors.Grey}the preview and the utility" + + $" in the world" + : $" {ChatColors.Green}cleared" + ); } [Command("bloom", registerRaw: false, permission: "")] @@ -891,8 +1002,11 @@ public void OnCrosshair(ICommandContext context) state.Crosshair = !state.Crosshair; // Redrawn rather than left until the player steps off the spot and back - // on: a toggle that appears to do nothing gets pressed again. - _replay.ClearMarkers(); + // on: a toggle that appears to do nothing gets pressed again. Only + // their selection -- the crosshair is theirs, and clearing every marker + // to redraw it blanked the map for everybody else. + _replay.ClearSelectionFor(player.SteamID); + ForgetSpot(player.SteamID); Reply( context, @@ -1024,8 +1138,19 @@ public void OnDelete(ICommandContext context) _library.Remove(player.SteamID, loaded); state.Results.RemoveAll(match => match.client_id == loaded.client_id); state.Loaded = null; + + if (state.Practising?.client_id == loaded.client_id) + { + state.Practising = null; + _replay.ApplyLibraryVisibility(player); + } + _replay.ClearGhosts(player.SteamID); + // Gone from their library, so gone from the map -- not left standing + // until somebody else's refresh happens to rebuild the layer. + RedrawLibrary(); + Reply(context, $" {ChatColors.Green}deleted {ChatColors.Default}{loaded.name}"); if (loaded.id == null) @@ -1069,11 +1194,18 @@ public void OnReload(ICommandContext context) PracticeState reloading = _system.StateFor(steamId); reloading.Loaded = null; + reloading.Practising = null; reloading.Results.Clear(); reloading.Index = -1; _replay.ClearGhosts(steamId); - _replay.ClearMarkers(); + + // Their selection, not the shared layer: other players' markers have + // nothing to do with this library, and the layer is redrawn from the + // new one the moment it lands (OnLibraryReplaced). + _replay.ClearSelectionFor(steamId); + _replay.ApplyLibraryVisibility(player); + ForgetSpot(steamId); _library.Refresh( steamId, @@ -1371,7 +1503,8 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Green}utility practice {ChatColors.Grey}-- infinite utility, buy anywhere", $" {ChatColors.Default}.save {ChatColors.Grey}saves the throw you just made", $" {ChatColors.Default}.load {ChatColors.Grey}stands you on a saved lineup", - $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}throws it again from where it was thrown", + $" {ChatColors.Grey}your {ChatColors.Default}ping key {ChatColors.Grey}clears smokes and fires", $" {ChatColors.Default}.help {ChatColors.Grey}everything else", }; @@ -1382,7 +1515,8 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.load {ChatColors.Grey}teleports you to a lineup", $" {ChatColors.Default}.next / .prev {ChatColors.Grey}walk the last search", $" {ChatColors.Default}.jump {ChatColors.Grey}stand where the loaded lineup lands", - $" {ChatColors.Default}.rethrow {ChatColors.Grey}back to the loaded lineup", + $" {ChatColors.Default}.rethrow {ChatColors.Grey}throws the loaded lineup again, without moving you", + $" {ChatColors.Default}.all {ChatColors.Grey}show every lineup again after loading one", $" {ChatColors.Default}.last / .back {ChatColors.Grey}back to a throw you made", $" {ChatColors.Default}.map / .here {ChatColors.Grey}pick off the minimap, or only what you can throw from here", $" {ChatColors.Default}.edit {ChatColors.Grey}rename the loaded lineup or change who sees it", @@ -1400,7 +1534,9 @@ public void OnRefresh(ICommandContext context) $" {ChatColors.Default}.colors {ChatColors.Grey}a colour per throw, smoke and trail", $" {ChatColors.Default}.crosshair {ChatColors.Grey}hide the aim marker and throw it blind", $" {ChatColors.Default}.spawns / .spawn next {ChatColors.Grey}where rounds start from", - $" {ChatColors.Default}.noclip / .god / .timer / .solo / .clear", + $" {ChatColors.Default}.clear {ChatColors.Grey}or your {ChatColors.Default}ping key" + + $" {ChatColors.Grey}-- smokes, fires and the preview", + $" {ChatColors.Default}.noclip / .god / .timer / .solo", }; private void StartPlaybook(IPlayer player, ICommandContext context) @@ -1553,7 +1689,32 @@ private void Apply(IPlayer player, LineupRecord lineup) return; } - _system.StateFor(player.SteamID).Loaded = lineup; + PracticeState state = _system.StateFor(player.SteamID); + LineupRecord? was = state.Practising; + + // Asking for a lineup is asking to see it: whatever .clear held off + // starts drawing again from here. + state.Cleared = false; + state.Loaded = lineup; + + // Loading one lineup is asking to practise THAT one, so the rest of + // the map is hidden from this player until .all. A drill and an + // execute pick their own throws and already draw only those, and + // leave the map whole when they end. + state.Practising = + _drill.Current(player.SteamID) == null && !_playbook.Running ? lineup : null; + + // Now as well as on the redraw Load makes: a library that has not + // changed is not rebuilt, so nothing else would apply this. + _replay.ApplyLibraryVisibility(player); + + if (state.Practising != null && was?.client_id != lineup.client_id) + { + player.SendChat( + $" {ChatColors.Green}practising {ChatColors.Default}{DrillUtility.Name(lineup)} {ChatColors.Grey}-- other lineups hidden, {ChatColors.Default}.all{ChatColors.Grey} to show them" + .Colored() + ); + } // Standing the player on the lineup needs nothing but the flat fields, // so it happens now; the line itself may still be a round trip away. @@ -1576,12 +1737,6 @@ private void Apply(IPlayer player, LineupRecord lineup) return; } - IPlayer? still = _system.Find(steamId); - - if (still != null && still.IsValid) - { - } - DrawBloom(steamId, fetched); } ); diff --git a/apps/utility-sw/src/Events/PracticePlayer.cs b/apps/utility-sw/src/Events/PracticePlayer.cs index 78361d2f..254be062 100644 --- a/apps/utility-sw/src/Events/PracticePlayer.cs +++ b/apps/utility-sw/src/Events/PracticePlayer.cs @@ -46,6 +46,31 @@ public HookResult OnPlayerJoinTeam(EventPlayerTeam @event) return HookResult.Continue; } + // The ping key, because that is where every other practice server put it + // and because clearing is the one thing you want BETWEEN two attempts -- + // the moment chat is worst at. The smoke in the way is in front of you now, + // not after you have typed six characters into it. + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerPing(EventPlayerPing @event) + { + IPlayer? player = @event.UserIdPlayer; + + if (player == null || !player.IsValid || player.IsFakeClient) + { + return HookResult.Continue; + } + + int cleared = _replay.ClearThrownUtility(); + + // Silent when there was nothing to clear: a ping is also just a ping. + if (cleared > 0) + { + Tell(player.SteamID, $" {ChatColors.Green}cleared {ChatColors.Grey}the utility in the world"); + } + + return HookResult.Continue; + } + [GameEventHandler(HookMode.Post)] public HookResult OnPlayerBlind(EventPlayerBlind @event) { @@ -56,12 +81,25 @@ public HookResult OnPlayerBlind(EventPlayerBlind @event) IPlayer? blinded = @event.UserIdPlayer; IPlayer? thrower = @event.AttackerPlayer; - // Only a bot's blindness is worth reporting. A player who flashed - // themselves already knows, and one who flashed a team-mate is told by - // the team-mate. - if (blinded != null && blinded.IsValid && blinded.IsFakeClient && duration > 0f) + if (duration > 0f && blinded != null && blinded.IsValid) { - ReportFlash(thrower, duration); + if (blinded.IsFakeClient) + { + ReportFlash(thrower, duration, "a bot"); + } + else + { + // NoFlash below zeroes the blindness, so the screen never goes + // white and the number IS the feedback: "how long would that + // have had me" is the whole question a flash lineup asks, and + // the server was answering it for the bot only. + ReportFlash(blinded, duration, "you"); + + if (thrower != null && thrower.IsValid && thrower.SteamID != blinded.SteamID) + { + ReportFlash(thrower, duration, blinded.Controller.PlayerName); + } + } } if (!_config.NoFlash) @@ -139,16 +177,16 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) private const int BotFullHealth = 100; - private void ReportFlash(IPlayer? thrower, float duration) + private void ReportFlash(IPlayer? told, float duration, string who) { - if (thrower == null || !thrower.IsValid || thrower.IsFakeClient) + if (told == null || !told.IsValid || told.IsFakeClient) { return; } Tell( - thrower.SteamID, - $" {ChatColors.Default}{duration:0.00}s {ChatColors.Grey}of flash on a bot" + told.SteamID, + $" {ChatColors.Default}{duration:0.00}s {ChatColors.Grey}of flash on {who}" ); } diff --git a/apps/utility-sw/src/Hud/PracticeEdit.cs b/apps/utility-sw/src/Hud/PracticeEdit.cs index 972777d7..b6858d16 100644 --- a/apps/utility-sw/src/Hud/PracticeEdit.cs +++ b/apps/utility-sw/src/Hud/PracticeEdit.cs @@ -215,6 +215,13 @@ private void SaveEdit(IPlayer player, EditDraft draft) Core.Scheduler.NextTick(() => { + // Answered after a reload tore this instance down: the panel it + // would redraw belongs to an instance that is gone. + if (_tornDown) + { + return; + } + IPlayer? still = _system.Find(steamId); if (still == null || !still.IsValid) diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index 8cc6ee3a..5e3c070c 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -36,6 +36,24 @@ private sealed class PendingFetch private readonly Dictionary _fetching = new(); + // What this server changed in a player's library itself -- a .save, a + // solve, a .delete -- and when, by client id. A fetch that went out before + // the change, or before the panel had it, answers with the library as it + // was; applied as-is it dropped the lineup somebody had just saved, or + // brought back the one they had just deleted. Kept is null for a removal. + private sealed class LocalChange + { + public DateTime At; + public LineupRecord? Kept; + } + + private readonly Dictionary> _local = new(); + + // How long a local change outranks the panel's answer once a fetch has + // started after it. A save is one round trip away from being on the panel; + // past this, a lineup missing from the answer is one the website removed. + private static readonly TimeSpan LocalHold = TimeSpan.FromMinutes(2); + public PracticeLibrary( ISwiftlyCore core, UtilityApiClient api, @@ -49,6 +67,25 @@ ILogger logger public string Map => _map; + // Set once this instance has been torn down. A fetch still in flight + // across a hot reload lands on a tick after the teardown, and everything + // waiting on it -- a pushed load, a redraw -- would teleport players and + // draw markers on behalf of an instance that no longer owns any of them. + private volatile bool _closed; + + public void Close() + { + _closed = true; + } + + // Raised on the game thread whenever a fetch replaces a player's library, + // before anyone waiting on that fetch is answered. Every refresh lands + // here -- the drain's, .reload's, a load pushed from the website -- so the + // markers follow the library whichever of them asked. Carries the client + // ids the refresh took away: archived or deleted on the website since the + // last answer. + public event Action>? Replaced; + public void SetMap(string map) { if (_map == map) @@ -58,6 +95,7 @@ public void SetMap(string map) _map = map; _lineups.Clear(); + _local.Clear(); } public IReadOnlyList For(ulong steamId) @@ -82,6 +120,7 @@ public void Add(ulong steamId, LineupRecord lineup) lineups.RemoveAll(existing => existing.client_id == lineup.client_id); lineups.Add(lineup); + Touch(steamId, lineup.client_id, lineup); } public void Remove(ulong steamId, LineupRecord lineup) @@ -90,6 +129,92 @@ public void Remove(ulong steamId, LineupRecord lineup) { lineups.RemoveAll(existing => existing.client_id == lineup.client_id); } + + Touch(steamId, lineup.client_id, null); + } + + private void Touch(ulong steamId, string clientId, LineupRecord? kept) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + changes = new Dictionary(); + _local[steamId] = changes; + } + + changes[clientId] = new LocalChange { At = DateTime.UtcNow, Kept = kept }; + } + + // The panel's answer, with this server's own changes laid back over it + // while the answer cannot have seen them yet. A change is forgotten once + // an answer agrees with it, or once it is old enough that disagreeing + // means somebody else changed it since. + private List MergeLocal(ulong steamId, DateTime startedAt, List answer) + { + if (!_local.TryGetValue(steamId, out Dictionary? changes)) + { + return answer; + } + + DateTime now = DateTime.UtcNow; + var merged = new List(answer); + + foreach ((string clientId, LocalChange change) in changes.ToList()) + { + int index = merged.FindIndex(row => row.client_id == clientId); + + // Made after the request left: the answer cannot know about it. + bool newer = change.At >= startedAt; + bool holding = newer || now - change.At < LocalHold; + + if (change.Kept == null) + { + if (index < 0) + { + if (!newer) + { + changes.Remove(clientId); + } + } + else if (holding) + { + merged.RemoveAt(index); + } + else + { + changes.Remove(clientId); + } + + continue; + } + + if (index >= 0) + { + if (newer) + { + merged[index] = change.Kept; + } + else + { + changes.Remove(clientId); + } + + continue; + } + + // Not on the panel yet. One with no id never reached it at all -- + // it is waiting in the retry queue -- so it stays for as long as it + // stays in this server's hands. + if (holding || string.IsNullOrEmpty(change.Kept.id)) + { + merged.Add(change.Kept); + } + else + { + changes.Remove(clientId); + } + } + + return merged; } // A library row carries no flight path and no measured bloom, so neither @@ -112,6 +237,11 @@ public void EnsureTrajectory(LineupRecord lineup, ulong steamId, Action { + if (_closed) + { + return; + } + if (artifact != null) { lineup.trajectory = artifact.path; @@ -195,6 +325,11 @@ private void Landed( List? lineups ) { + if (_closed) + { + return; + } + // An answer to a request that was already given up on. The callers it // was carrying moved to the one that replaced it, and its rows are the // older of the two. @@ -211,8 +346,29 @@ private void Landed( // answer beats showing inferno lineups on mirage. if (lineups != null && map == _map) { + lineups = MergeLocal(steamId, fetch.StartedAt, lineups); + + var dropped = new HashSet(); + + if (_lineups.TryGetValue(steamId, out List? was)) + { + dropped.UnionWith(was.Select(lineup => lineup.client_id)); + dropped.ExceptWith(lineups.Select(lineup => lineup.client_id)); + } + _lineups[steamId] = lineups; count = lineups.Count; + + try + { + Replaced?.Invoke(steamId, dropped); + } + catch (Exception error) + { + // Same reason as the callbacks below: nothing thrown here may + // reach native code, or keep the waiting callers unanswered. + _logger.LogError(error, "a library replaced handler threw"); + } } foreach (Action waiting in fetch.Waiting) diff --git a/apps/utility-sw/src/Services/PracticeReplay.cs b/apps/utility-sw/src/Services/PracticeReplay.cs index 10b5da4b..b3600e8d 100644 --- a/apps/utility-sw/src/Services/PracticeReplay.cs +++ b/apps/utility-sw/src/Services/PracticeReplay.cs @@ -152,6 +152,13 @@ private static TraceParams SkipMarkers() // anything heavier. private const float MarkerWidth = 0.6f; + // The aim reticle's half-size per unit of distance from the stance eye: + // roughly 2.5 degrees, so it covers the same slice of the view at any + // range. The minimums only stop a zero distance drawing nothing. + private const float AimSizePerUnit = 0.045f; + private const float AimMinSize = 0.25f; + private const float AimMinWidth = 0.02f; + // How far away the model's outline stays visible. Bounded: across the whole // map every spot glowing through every wall is noise, not guidance. private const int UtilityGlowRange = 1500; @@ -171,7 +178,8 @@ private static TraceParams SkipMarkers() // The library layer: every lineup's stance ring, landing ring, name and // grenade model. Shared on purpose -- everyone on the server should see - // where the lineups are. + // where the lineups are -- and only transmit-blocked from a player who is + // practising one lineup (see ApplyLibraryVisibility). private readonly List _markerBeams = new(); // Spawn rings live outside the library layer on purpose: ShowLibrary @@ -182,6 +190,12 @@ private static TraceParams SkipMarkers() private readonly List _markerTexts = new(); private readonly List _markerProps = new(); + // Which lineups each library entity stands for, by entity index. A needle + // and a diamond belong to one lineup; a floating grenade belongs to every + // lineup of its type thrown from that spot. This is what lets one shared + // set of entities show each player only the lineups the panel gave THEM. + private readonly Dictionary _markerLineups = new(); + // The selection layer: the crosshair and labels for whichever lineup ONE // player has focused. Kept per player and transmit-blocked from everybody // else, because two people practising at once were otherwise wiping each @@ -306,6 +320,17 @@ private class GhostThrow private readonly Dictionary _ghostThrows = new(); + // Set by the plugin's teardown. Nothing is spawned past it: a callback + // that outlives a hot reload -- a library answer, a trajectory fetch, a + // delayed redraw -- would otherwise put entities in the world after the + // sweep, where no instance holds a handle to them. + private volatile bool _closed; + + public void Close() + { + _closed = true; + } + public PracticeReplay( ISwiftlyCore core, UtilityConfig config, @@ -329,8 +354,12 @@ ILogger logger // channel that fades. public Func AnnouncesLoad { get; set; } = _ => true; + // Whether this player is practising one lineup they loaded, and so should + // see only that one. Wired by the plugin, which owns the state. + public Func LibraryHidden { get; set; } = _ => false; + // The whole library for a player, so loading one lineup still draws the - // rest. Supplied by the plugin, which owns the library. + // rest for everybody else. Supplied by the plugin, which owns the library. /// /// Narrows the library layer to a named set of lineups, IN ORDER. /// @@ -401,6 +430,15 @@ private static Color Faded(Color color, float visibility) public Func> All { get; set; } = _ => Array.Empty(); + // What the library layer draws: every connected player's library merged + // into one set, because the layer is one set of entities for the whole + // server. Drawing the CALLER's library there replaced everybody else's + // markers with theirs on every .load, and put their private lineups in + // front of the room. Who may actually see each lineup is decided per + // viewer afterwards, in ApplyLibraryVisibility. + public Func> Shared { get; set; } = + () => Array.Empty(); + // A capture client watching a lineup wants the throw and not the plugin's // drawing of it: beams in frame are our overlay filmed instead of the map. @@ -560,21 +598,34 @@ public void Load(IPlayer player, LineupRecord lineup) IReadOnlyList library = everything.Count > 0 ? everything : new[] { lineup }; - // Whatever else is throwable from this spot comes up with it. - List here = SpotAt(library, standing); + // The layer everyone shares, not this player's slice of it: their + // library decides only what their own selection offers below. + IReadOnlyList shared = Shared(); + + // Whatever else is throwable from this spot comes up with it -- + // unless the player is practising this one, where a sibling + // crosshair is exactly the "which one am I on" they loaded it to + // avoid. + List here = LibraryHidden(player.SteamID) + ? new List() + : SpotAt(library, standing); if (!here.Any(entry => entry.client_id == lineup.client_id)) { here.Add(lineup); } - ShowLibrary(library); + ShowLibrary(shared.Count > 0 ? shared : library); ShowSelection(player, here, standing); }); if (AnnouncesLoad(player.SteamID)) { - player.SendCenter(Describe(lineup)); + player.SendCenter( + LibraryHidden(player.SteamID) + ? $"{Describe(lineup)}\n.all shows every lineup" + : Describe(lineup) + ); } } @@ -781,7 +832,7 @@ public int ShowBloom(IPlayer player, LineupRecord lineup) // subject. Only Swiftly can emit one, so only Swiftly offers it. public bool ShowBloomSmoke(IPlayer player, LineupRecord lineup) { - if (!EmitGrenades) + if (!EmitGrenades || _closed) { return false; } @@ -854,12 +905,21 @@ public void ClearBloomSmoke(ulong steamId) // Tier 2: a real grenade, launched from the physics seed the engine gave // us at record time rather than from the player's eye angles, so it lands // where the recorded one did instead of near it. - public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) + // force is .rethrow asking by name. The switch below is what a render pod + // sets to have every load throw itself; a player typing the command has + // already said which throw they want, so it is not the switch's to refuse. + public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup, bool force = false) { + if (_closed) + { + return; + } + _logger.LogInformation( - "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", + "[nade-render] ThrowGhostProjectile: emitGrenades={emit} ghostProjectile={ghost} forced={force} exactlyReplayable={exact} hasSeed={seed} confidence={conf}", EmitGrenades, _config.GhostProjectile, + force, lineup.IsExactlyReplayable(), lineup.HasPhysicsSeed(), lineup.confidence @@ -871,7 +931,7 @@ public void ThrowGhostProjectile(IPlayer player, LineupRecord lineup) return; } - if (!_config.GhostProjectile) + if (!_config.GhostProjectile && !force) { _logger.LogInformation("[nade-render] skip: GhostProjectile off"); return; @@ -1265,7 +1325,9 @@ public void ShowMarkersFor(IPlayer player, LineupRecord lineup) here.Add(lineup); } - ShowLibrary(library); + IReadOnlyList shared = Shared(); + + ShowLibrary(shared.Count > 0 ? shared : library); ShowSelection(player, here, standing); } @@ -1359,6 +1421,11 @@ public void ShowLibrary(IEnumerable lineups) if (signature == _librarySignature && _markerBeams.Count > 0) { + // Nothing to rebuild, but who may see what can still have moved: + // a lineup made public arrives in somebody else's library without + // changing a single marker. + ApplyLibraryVisibility(); + return; } @@ -1399,11 +1466,118 @@ public void ShowLibrary(IEnumerable lineups) // No name on the ground and no post: the chevron says which way, // the glowing model says what and where, and the name arrives in // centre text when the player points at the grenade. + int before = _markerBeams.Count; + Needle(feet, lineup.release.yaw, 13f, needle, MarkerWidth); Diamond(lineup.detonation_position, 22f, type, MarkerWidth); + + TagLineups(_markerBeams.Skip(before), new[] { lineup.client_id }); } ShowSpotUtility(drawn); + + // New entities start out sent to everybody, so whoever was practising + // one lineup would get the whole map back on every rebuild. + ApplyLibraryVisibility(); + } + + /// + /// Hides the library layer from whoever is practising one lineup, and + /// shows it to everyone else. Per viewer, so one player narrowing their + /// view leaves the rest of the server seeing the whole map. Their own + /// selection is a separate set of entities and is never touched here. + /// + /// The layer is everybody's libraries at once, so each lineup is also + /// hidden from anyone the panel did not give it to: a private lineup is + /// seen by its owner and nobody else. A lineup in nobody's library -- one + /// loaded straight off the website, say -- has no owner to protect and + /// stays visible, as it always was. + /// + /// Not while an execute is running: that already narrows the library to + /// its own throws, and hiding those would hide the execute. + /// + public void ApplyLibraryVisibility(IPlayer? only = null) + { + var entities = new List<(int index, string[]? lineups)>(); + + foreach (CEnvBeam beam in _markerBeams) + { + if (beam.IsValid) + { + entities.Add(Tracked(beam.Index)); + } + } + + foreach (CPointWorldText label in _markerTexts) + { + if (label.IsValid) + { + entities.Add(Tracked(label.Index)); + } + } + + foreach (CPhysicsProp prop in _markerProps) + { + if (prop.IsValid) + { + entities.Add(Tracked(prop.Index)); + } + } + + var humans = new List(); + + foreach (IPlayer player in _core.PlayerManager.GetAllPlayers()) + { + if (player != null && player.IsValid && !player.IsFakeClient) + { + humans.Add(player); + } + } + + // Read once per pass rather than once per entity: a busy map is over a + // thousand entities, and the library behind each player does not move + // while this runs. + var libraries = new Dictionary>(); + var owned = new HashSet(); + + foreach (IPlayer human in humans) + { + var mine = new HashSet(All(human.SteamID).Select(lineup => lineup.client_id)); + + libraries[human.SteamID] = mine; + owned.UnionWith(mine); + } + + bool restricted = LibraryRestriction != null; + + foreach (IPlayer player in humans) + { + if (only != null && player.SteamID != only.SteamID) + { + continue; + } + + bool hideAll = !restricted && LibraryHidden(player.SteamID); + HashSet mine = libraries[player.SteamID]; + + foreach ((int index, string[]? lineups) in entities) + { + bool hide = + hideAll + || ( + !restricted + && lineups != null + && !lineups.Any(id => mine.Contains(id) || !owned.Contains(id)) + ); + + player.ShouldBlockTransmitEntity(index, hide); + } + } + } + + private (int index, string[]? lineups) Tracked(uint index) + { + return ((int)index, _markerLineups.TryGetValue(index, out string[]? lineups) ? lineups : null); } // What to bring, not which throw to make. A model belongs to the SPOT: two @@ -1439,11 +1613,56 @@ private void ShowSpotUtility(IReadOnlyList lineups) // is the one that does not say which throw it is. Color? glow = StepGlowAt(lineups, spot.types[index], spot.x, spot.y, spot.z); + int before = _markerProps.Count; + UtilityModel( spot.types[index], new Vec3(spot.x + offset, spot.y, spot.z), glow ); + + TagLineups( + _markerProps.Skip(before), + LineupsAt(lineups, spot.types[index], spot.x, spot.y, spot.z) + ); + } + } + } + + // The same "is this lineup part of that spot" test StepGlowAt makes, so a + // grenade is shown to whoever can see any one of the throws it stands for. + private string[] LineupsAt( + IReadOnlyList lineups, + string utilityType, + float x, + float y, + float z + ) + { + return lineups + .Where(lineup => + { + if (lineup.utility_type != utilityType) + { + return false; + } + + Vec3 feet = Grounded(lineup.release.feet_position); + + return new Vec3(feet.x - x, feet.y - y, 0f).LengthXY() <= SpotRadius + && Math.Abs(feet.z - z) <= SpotHeight; + }) + .Select(lineup => lineup.client_id) + .ToArray(); + } + + private void TagLineups(IEnumerable entities, string[] lineups) + { + foreach (CEntityInstance entity in entities) + { + if (entity.IsValid) + { + _markerLineups[entity.Index] = lineups; } } } @@ -1729,12 +1948,10 @@ private void ShowMarkers(LineupRecord lineup) Color color = ColorFor(lineup.utility_type); Vec3 landing = lineup.detonation_position; + // No text over the landing: the diamond and its colour already say + // where it goes and what it is, and a word hung in the air above the + // smoke only got in the way of seeing it. Diamond(landing, 30f, color, MarkerWidth); - Label( - new Vec3(landing.x, landing.y, landing.z + 16f), - PracticeLineupUtility.Tracked(lineup.utility_type), - color - ); AimReticle(lineup, lineup.name); } @@ -1827,7 +2044,13 @@ private void AimReticle(LineupRecord lineup, string label) // wall is ten units away or two thousand. // Tighter than a "look over there" marker: this is a point to cover // with the crosshair, so it subtends a few degrees and no more. - float size = Math.Clamp(away * 0.045f, 9f, 110f); + // + // The floor used to be 9 units, which held for anything past ~200 + // units but blew up up close: facing a wall 10 units off, a 9-unit + // reticle filled most of the screen and the aim point inside it could + // not be found. Proportional all the way down keeps the same ~2.5 + // degrees at any range; the floor only guards a degenerate distance. + float size = Math.Clamp(away * AimSizePerUnit, AimMinSize, 110f); // Deliberately not the utility's colour: this is the only marker that // is not a place the utility goes, and it has to separate from the @@ -1835,7 +2058,14 @@ private void AimReticle(LineupRecord lineup, string label) // Thin lines vanish at range, so the reticle's weight grows with // distance the same way its size does. The ground rings never need // this: you are always standing on them. - float weight = Math.Clamp(away * 0.0018f, MarkerWidth, 2.2f); + // The floor scales too, for the same reason as the size: a fixed + // 0.6-unit beam is a hair at range but a bar across the view from a + // wall a few units away. Unchanged from ~100 units out. + float weight = Math.Clamp( + away * 0.0018f, + Math.Max(Math.Min(MarkerWidth, away * 0.006f), AimMinWidth), + 2.2f + ); // Every throw off the spot is drawn at the same size and weight. Which // one you are on is said in COLOUR, not in scale: a smaller crosshair @@ -1900,7 +2130,16 @@ private void AimReticle(LineupRecord lineup, string label) // Amber, and never repainted: the label names the throw, the beams // carry the miss signal, and splitting the jobs means the label's own // colour networking never becomes a question. - Label(new Vec3(center.x, center.y, center.z + size + 8f), label, Amber); + // Shrinks with the reticle up close for the same reason the reticle + // does: full-size lettering on a wall a few units off covered the very + // point it was naming. Unchanged from ~200 units out. + float labelScale = Math.Min(1f, size / 9f); + Label( + new Vec3(center.x, center.y, center.z + size + 8f * labelScale), + label, + Amber, + LabelUnitsPerPx * labelScale + ); _drawingInto?.Aims.Add(aim); } @@ -2007,7 +2246,9 @@ Vec3 Corner(float x, float y) => // The point itself: a dot small enough that covering it with the // crosshair means covering the exact spot the throw was aimed at. - float dot = Math.Max(size * 0.03f, 0.6f); + // Its floor shrinks with the reticle: a fixed 0.6 swamped a reticle + // drawn on a wall a few units away, hiding the point it marks. + float dot = Math.Max(size * 0.03f, Math.Min(size * 0.067f, 0.6f)); AddMarkerBeam(Corner(-dot, 0), Corner(dot, 0), color, width * 1.6f); AddMarkerBeam(Corner(0, -dot), Corner(0, dot), color, width * 1.6f); @@ -2168,6 +2409,62 @@ private static CEntityKeyValues Tagged() return keys; } + // Live utility, as opposed to the preview drawn over it. Smokes and fires + // are what stands between two attempts at the same lineup, and anything + // still in the air goes with them: a rethrow answered by the grenade you + // are trying to replace is not an answer. + private static readonly string[] ThrownUtilityClasses = + { + "smokegrenade_projectile", + "flashbang_projectile", + "hegrenade_projectile", + "molotov_projectile", + "decoy_projectile", + "inferno", + }; + + public int ClearThrownUtility() + { + int cleared = 0; + + foreach (string designer in ThrownUtilityClasses) + { + try + { + foreach ( + CBaseEntity entity in _core.EntitySystem.GetAllEntitiesByDesignerName( + designer + ) + ) + { + if (!entity.IsValid) + { + continue; + } + + // Taken out of the world by hand, so nothing will ever + // report its detonation: the bookkeeping goes with it or it + // sits in the table until the reaper times it out. + _ghostThrows.Remove(entity.Index); + + entity.Despawn(); + cleared += 1; + } + } + catch (Exception error) + { + _logger.LogWarning(error, "unable to clear {designer}", designer); + } + } + + // The bloom preview is a real smoke, so it went with the rest. Its + // handles have to go too: a dead one can be recycled into a new entity, + // and despawning THAT is worse than leaving the entry behind. + _bloomSmoke.Clear(); + + return cleared; + } + // Despawns every marker in the world, ours or a previous instance's, then // forgets the handles. Safe to call when there is nothing to find. public int SweepMarkers() @@ -2268,7 +2565,7 @@ private void Diamond(Vec3 at, float radius, Color color, float width) private void UtilityModel(string utilityType, Vec3 at, Color? glow = null) { - if (!DrawModels) + if (!DrawModels || _closed) { return; } @@ -2410,9 +2707,14 @@ private static Vec3 Normalize(Vec3 v) return length < 0.0001f ? v : new Vec3(v.x / length, v.y / length, v.z / length); } - private CPointWorldText? Label(Vec3 at, string text, Color color) + private CPointWorldText? Label( + Vec3 at, + string text, + Color color, + float unitsPerPx = LabelUnitsPerPx + ) { - if (!Sane(at)) + if (_closed || !Sane(at)) { return null; } @@ -2456,7 +2758,7 @@ private static Vec3 Normalize(Vec3 v) // across the map from it. 60px at 0.15 units/px was roughly two // metres of lettering standing in a doorway. label.FontSize = LabelFontSize; - label.WorldUnitsPerPx = LabelUnitsPerPx; + label.WorldUnitsPerPx = unitsPerPx; var angle = new QAngle(0, LabelYaw, 0); @@ -2507,10 +2809,25 @@ public void ForgetMarkers() _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); + _markerLineups.Clear(); _selections.Clear(); _drawingInto = null; } + // Every handle this instance holds, markers and ghosts and previews alike, + // dropped without touching the world. For the moments the world was + // emptied under us -- a round restart's map cleanup, a map change -- when + // each handle may already be recycled into an entity that is somebody + // else's, and despawning it would take THAT out instead. + public void ForgetEverything() + { + _ghosts.Clear(); + _trails.Clear(); + _ghostThrows.Clear(); + _bloomSmoke.Clear(); + ForgetMarkers(); + } + // The library layer only. The selection layer belongs to individual players // and outlives a library redraw. private void ClearSharedMarkers() @@ -2542,6 +2859,7 @@ private void ClearSharedMarkers() _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); + _markerLineups.Clear(); } public void ClearMarkers() @@ -2580,6 +2898,7 @@ public void ClearMarkers() _markerBeams.Clear(); _markerTexts.Clear(); _markerProps.Clear(); + _markerLineups.Clear(); } // A NaN or an infinity reaching Teleport takes the whole server down inside @@ -2592,6 +2911,11 @@ private static bool Sane(Vec3 point) private CEnvBeam? CreateBeam(Vec3 start, Vec3 end, Color color, float width) { + if (_closed) + { + return null; + } + if (!Sane(start) || !Sane(end)) { _logger.LogWarning( diff --git a/apps/utility-sw/src/Services/PracticeSystem.cs b/apps/utility-sw/src/Services/PracticeSystem.cs index 4f05c2c6..865d1de8 100644 --- a/apps/utility-sw/src/Services/PracticeSystem.cs +++ b/apps/utility-sw/src/Services/PracticeSystem.cs @@ -13,6 +13,13 @@ public class PracticeState { public LineupRecord? Loaded { get; set; } + // The lineup this player explicitly loaded to practise -- .load, .next, a + // pick off the panel or the website. While set, every other lineup is + // hidden from them alone. Separate from Loaded because that one follows + // whichever ring they look at, and glancing at a spot must not be what + // empties the map. .all puts it back to null. + public LineupRecord? Practising { get; set; } + // The last query's matches, so .next and .prev walk them in place. public List Results { get; } = new List(); public int Index { get; set; } = -1; @@ -57,6 +64,13 @@ public class PracticeState // has not asked for it should not be paying for it. public bool Bloom { get; set; } + // Set by .clear and held until the player asks for something again. The + // spot watcher redraws whatever is under their feet four times a second + // and re-adopts the lineup they are looking at, so without a flag saying + // "they asked for nothing" it put the markers back inside a quarter of a + // second -- which is .clear appearing to do nothing at all. + public bool Cleared { get; set; } + // Lineups this player has already been told are not exact. Said once per // lineup: a warning repeated on every .rethrow is a warning nobody reads. @@ -120,6 +134,18 @@ public bool IsSolo(ulong steamId) return !_states.TryGetValue(steamId, out PracticeState? state) || state.Solo; } + // Same shape as IsSolo: no state means nothing loaded, so nothing hidden. + public bool IsPractising(ulong steamId) + { + return _states.TryGetValue(steamId, out PracticeState? state) && state.Practising != null; + } + + // Same shape again: .clear holds until the player asks for something. + public bool IsCleared(ulong steamId) + { + return _states.TryGetValue(steamId, out PracticeState? state) && state.Cleared; + } + public void Forget(ulong steamId) { _states.Remove(steamId); diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 3bfacab9..344ffe2c 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -85,6 +85,11 @@ private sealed class LibraryLoad private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; private EventDelegates.OnCustomHudClicked? _hudClickHandler; + // Hooked by hand, so unhooked by hand: SwiftlyS2 only drops it once the + // core is disposed, a step after Unload, and a round that starts in + // between still reaches this instance's handler. + private Guid _roundStartHook; + public UtilityPracticePlugin(ISwiftlyCore core) : base(core) { } @@ -143,7 +148,15 @@ public override void Load(bool hotReload) _replay.IsSolo = _system.IsSolo; _replay.AnnouncesLoad = steamId => !UseHud(steamId); + // .clear hides the library the same way practising one lineup does: + // from the player who asked, and nobody else. Hidden rather than + // swept, so no redraw -- a save, a refresh, a round restart -- can hand + // it back to them, and nobody else loses theirs. + _replay.LibraryHidden = steamId => + _system.IsPractising(steamId) || _system.IsCleared(steamId); _replay.All = steamId => _library.For(steamId); + _replay.Shared = SharedLibrary; + _library.Replaced += OnLibraryReplaced; // A solve rains live HE and molotovs on a map people are standing in. _system.SolveRunning = () => _solver.IsBusy; _session.Refreshed += OnSessionRefreshed; @@ -197,7 +210,7 @@ public override void Load(bool hotReload) CEntityInstance entity = @event.Entity; Core.Scheduler.NextTick(() => { - if (entity.IsValid) + if (!_tornDown && entity.IsValid) { _recorder.OnProjectileCreated(entity); TintSmoke(entity); @@ -209,10 +222,20 @@ public override void Load(bool hotReload) _mapLoadHandler = @event => OnMapLoad(@event.MapName); Core.Event.OnMapLoad += _mapLoadHandler; - Core.GameEvent.HookPre(_ => + _roundStartHook = Core.GameEvent.HookPre(_ => { + if (_tornDown) + { + return HookResult.Continue; + } + KeepRoundsMoving(); + // A new round is a map cleanup, and the cleanup takes every beam, + // label and grenade model with it. Nothing else redraws them: the + // library is only rebuilt when it changes, and it has not. + Core.Scheduler.NextTick(() => OnRoundRestarted()); + return HookResult.Continue; }); @@ -236,6 +259,11 @@ public override void Load(bool hotReload) _occupancyDirty = true; _prompt?.Cancel(@event.PlayerId); + // Their lineups leave the shared layer with them. A tick late, for + // the same reason the occupancy is: the leaving player is still on + // the roster while this runs. + Core.Scheduler.NextTick(() => RedrawLibrary()); + ForPlayer( @event.PlayerId, steamId => @@ -289,6 +317,12 @@ public override void Load(bool hotReload) _library.SetMap(current); _session.Map = current; ApplyPracticeCfg(); + + // Nobody reconnects for a hot reload, so no connect hook fires for + // the players already here. RefreshEverything fetches each of their + // libraries -- the drain picks up anyone it misses -- and the + // occupancy goes out on the next second rather than in a minute. + _occupancyDirty = true; RefreshEverything(); } @@ -300,7 +334,9 @@ public override void Load(bool hotReload) ); } - private bool _tornDown; + // Volatile: Unload runs on a thread pool thread during a hot reload, and + // continuations of the panel calls land on whichever thread they like. + private volatile bool _tornDown; // SwiftlyS2 never reschedules a repeating timer whose callback throws, // still runs a timer's first pass if it was cancelled before then, and @@ -358,6 +394,8 @@ public override void Unload() _logger.LogWarning(exception, "teardown failed"); } + FlushQueued(); + return; } @@ -378,6 +416,44 @@ public override void Unload() { _logger.LogWarning(exception, "teardown failed"); } + + FlushQueued(); + } + + // Saves and scored attempts the panel has not taken yet live only in this + // instance's memory, and a reload or a shutdown throws them away. One + // last try on the way out: waited for briefly, and only off the game + // thread -- a server shutting down is not held up for a panel that is + // down, it just loses what it could not send. + private static readonly TimeSpan FlushWait = TimeSpan.FromSeconds(3); + + private void FlushQueued() + { + try + { + int queued = _api?.QueuedCount ?? 0; + + if (queued == 0) + { + return; + } + + Task drain = Task.Run(() => _api!.Drain()); + + if (!Thread.CurrentThread.IsThreadPoolThread || drain.Wait(FlushWait)) + { + return; + } + + _logger.LogWarning( + "{queued} queued upload(s) still unsent at unload; they go with this instance", + _api!.QueuedCount + ); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "flushing the upload queue failed"); + } } // A tick is milliseconds away. Only a server that has stopped running them @@ -388,12 +464,18 @@ private void TearDown() { _tornDown = true; + // Before the sweep, so nothing that lands after it -- a library answer, + // a trajectory, a queued redraw -- can put anything back. + _library.Close(); + _replay.Close(); + // Drawn entities are not the plugin's to leave behind: without this a // hot reload orphans every beam, label and model in the world, with no // instance left holding a reference to any of them. _replay.SweepMarkers(); _session.Refreshed -= OnSessionRefreshed; + _library.Replaced -= OnLibraryReplaced; _recorder.Thrown -= _system.OnThrown; _recorder.Finalized -= _score.OnFinalized; _recorder.Ended -= _replay.TrailEnded; @@ -431,11 +513,17 @@ private void TearDown() if (_disconnectHandler != null) { Core.Event.OnClientDisconnected -= _disconnectHandler; + } - if (_precacheHandler != null) - { - Core.Event.OnPrecacheResource -= _precacheHandler; - } + if (_precacheHandler != null) + { + Core.Event.OnPrecacheResource -= _precacheHandler; + } + + if (_roundStartHook != Guid.Empty) + { + Core.GameEvent.Unhook(_roundStartHook); + _roundStartHook = Guid.Empty; } if (_authorizeHandler != null) @@ -526,6 +614,14 @@ private static float ToleranceFor(LineupRecord lineup) // light up everything throwable from it without redrawing every tick. private readonly Dictionary _standingIn = new(); + // What the watcher believes is drawn for a player. Anything that wipes the + // world without moving them has to say so here: left stale, the key matches + // on the next pass and suppresses the redraw that was supposed to follow. + private void ForgetSpot(ulong steamId) + { + _standingIn.Remove(steamId); + } + // IN_USE. Read every tick rather than on the 4Hz spot sweep because a tap // is shorter than a quarter of a second and a walk-up that does nothing is // worse than not offering it. @@ -606,6 +702,82 @@ private void PlaceBots() return; } + // A bot added to a live round never gets a spawn of its own: nothing + // here ends a round (mp_ignore_round_win_conditions), so the second + // .bot only ever added a dead name to the scoreboard. The same call + // brings back one a practice HE killed outright. + // + // Placed a beat later when anything was revived: a respawn has no pawn + // until the engine has run a tick, and the spots are handed out in + // order -- standing up only the bots that are already alive would give + // one of them the spot belonging to the bot still on its way back. + if (ReviveBots()) + { + Core.Scheduler.DelayBySeconds(BotPlaceDelaySeconds, StandBotsOnSpots); + + return; + } + + StandBotsOnSpots(); + } + + // A bot on no team cannot be spawned, and asking would put the engine in + // the position of choosing one -- which is how a bot ends up playing the + // round it was placed to stand still through. + private bool ReviveBots() + { + bool revived = false; + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + if (player == null || !player.IsValid || !player.IsFakeClient || player.IsAlive) + { + continue; + } + + if (player.Controller.Team is not (Team.CT or Team.T)) + { + continue; + } + + player.Respawn(); + revived = true; + } + + return revived; + } + + // Nothing here brings a bot back on its own -- see PlaceBots -- so a dead + // one is a target that has quietly stopped being one. Checked rather than + // run blind: with every bot up this is one loop and no teleports. + private void KeepBotsStanding() + { + if (_bots.Count == 0) + { + return; + } + + foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) + { + // Only one that ReviveBots can bring back: a dead bot on no team + // would otherwise re-teleport every live bot on every tick. + if ( + player != null + && player.IsValid + && player.IsFakeClient + && !player.IsAlive + && player.Controller.Team is Team.CT or Team.T + ) + { + PlaceBots(); + + return; + } + } + } + + private void StandBotsOnSpots() + { int index = 0; foreach (IPlayer player in Core.PlayerManager.GetAllPlayers()) @@ -691,7 +863,12 @@ private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); var at = new Vec3(origin.X, origin.Y, origin.Z); - IReadOnlyList library = _library.For(player.SteamID); + // Practising one lineup, the rest of the map is hidden from them, and + // use must not stand them on a ring they cannot see. + LineupRecord? practising = _system.StateFor(player.SteamID).Practising; + + IReadOnlyList library = + practising != null ? new[] { practising } : _library.For(player.SteamID); if (library.Count == 0) { @@ -726,7 +903,12 @@ private void StandOnNearest(IPlayer player, CCSPlayerPawn pawn) if (_replay.StandOn(player, target)) { - _system.StateFor(player.SteamID).Loaded = target; + PracticeState state = _system.StateFor(player.SteamID); + + // Walking onto a spot on purpose is asking to see it again. + state.Cleared = false; + state.Loaded = target; + _replay.ApplyLibraryVisibility(player); } } @@ -768,6 +950,14 @@ private void SpotWatch() continue; } + // .clear asked for an empty world, and this is the loop that would + // otherwise hand it straight back: both the markers and the + // Loaded assignment below. + if (_system.StateFor(player.SteamID).Cleared) + { + continue; + } + Vector origin = pawn.AbsOrigin ?? new Vector(0, 0, 0); var at = new Vec3(origin.X, origin.Y, origin.Z); @@ -797,10 +987,14 @@ private void SpotWatch() // chosen for them. LineupRecord? drilling = _drill.Current(player.SteamID); - if (drilling != null) + // Practising one lineup is the same ask: that throw and nothing + // else, wherever they walk. + LineupRecord? only = drilling ?? _system.StateFor(player.SteamID).Practising; + + if (only != null) { - show = new List { drilling }; - aimedAt = drilling; + show = new List { only }; + aimedAt = only; } string key = string.Join( @@ -1495,6 +1689,7 @@ private void OnSecond() _session.RetryIfMissing(TimeSpan.FromSeconds(15)); EndWarmup(); RespawnTheDead(); + KeepBotsStanding(); KeepEveryoneStocked(); ReportOccupancy(); _system.Tick(); @@ -1505,6 +1700,33 @@ private void OnSecond() _callouts.Report(_session.Map); DrainLibraryLoads(); DrainPendingMapLoad(); + DrainQueued(); + } + + // The retry queue only used to move when something else got through -- a + // later save, a scored throw, a map change -- so a panel that came back + // while nobody was saving left everything queued until the next one. + private const int DrainEverySeconds = 30; + + private int _drainTicks; + + private void DrainQueued() + { + if (++_drainTicks < DrainEverySeconds) + { + return; + } + + _drainTicks = 0; + + if (_api.QueuedCount == 0) + { + return; + } + + // Off the game thread like every other call to the panel; Drain lets + // only one pass run at a time, so a slow one is never stacked on. + _ = Task.Run(() => _api.Drain()); } // Nobody stays dead on a practice server. Rejoining while dead, falling off @@ -1777,6 +1999,10 @@ private void ForPlayer(int playerId, Action action) private void OnMapLoad(string mapName) { + // First: the resets below despawn whatever they still hold a handle + // to, and every one of those handles died with the old map. + _replay.ForgetEverything(); + _hud?.Reset(); _menus.Clear(); _recorder.Reset(); @@ -1795,6 +2021,16 @@ private void OnMapLoad(string mapName) _library.SetMap(mapName); _session.Map = mapName; + + // Loaded-for is compared by map name, so a changelevel onto the map + // the server was already on left every player marked as done: the + // drain never asked again, and anyone not caught by the refresh below + // -- still reconnecting when it ran -- kept whatever they had before. + foreach (LibraryLoad load in _libraryLoads.Values) + { + load.LoadedFor = null; + } + _callouts.Reset(); _callouts.Report(mapName); @@ -1804,6 +2040,29 @@ private void OnMapLoad(string mapName) RefreshEverything(); } + // Warmup ending, mp_restartgame and a round running out all come through + // here, a tick after round_start so the cleanup is certainly behind us. + // Swept by tag first, in case anything of ours did survive it, then every + // handle is dropped -- what is left of them may point at recycled + // entities -- and the shared layer and each player's selection are drawn + // again from scratch. + private void OnRoundRestarted() + { + if (_tornDown) + { + return; + } + + _replay.SweepMarkers(); + _replay.ForgetEverything(); + + // The spot watcher only redraws a selection when the player moves to a + // different spot. Forgotten, it redraws each one on its next pass. + _standingIn.Clear(); + + RedrawLibrary(); + } + // The panel is the only source of both the roster and the library, so a // refresh is one round trip followed by one per connected player. // @@ -1838,6 +2097,17 @@ private void RefreshAndShow(ulong steamId, bool pushed) load.FetchingSince = now; + // A player who has only just arrived is sent every marker on the map + // until somebody says otherwise, and their library is still a round + // trip away. With nothing of their own yet they see only what nobody + // owns, rather than everybody's private lineups for a second or two. + IPlayer? player = _system.Find(steamId); + + if (player != null) + { + _replay.ApplyLibraryVisibility(player); + } + _library.Refresh(steamId, count => LibraryLanded(steamId, map, count, pushed)); } @@ -1879,8 +2149,6 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) { IReadOnlyList library = _library.For(steamId); - ShowLibraryFor(steamId, library); - // .next and .prev walk state.Results, and a refresh never filled it // -- so every lineup on the map was drawn and none of them could be // stepped through until the player ran a search. If they can SEE @@ -1894,9 +2162,18 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) if (pushed || state.Results.Count == 0) { + // Where they were in the walk, by lineup rather than position: + // the panel pushing an edit is not the player asking to start + // again from the top, and the new rows are new objects anyway. + string? at = + state.Index >= 0 && state.Index < state.Results.Count + ? state.Results[state.Index].client_id + : null; + state.Results.Clear(); state.Results.AddRange(library); - state.Index = -1; + state.Index = + at == null ? -1 : state.Results.FindIndex(match => match.client_id == at); if (!pushed) { @@ -1918,20 +2195,116 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) // Markers are one shared set of entities for the whole server (see // PracticeReplay.ShowLibrary) while a library is filtered per player by the - // panel, so drawing one on somebody's behalf only holds while there is - // nobody else it could be shown to. With company it drew whoever's fetch - // landed last, which put one player's private lineups in front of everyone. - // Their own .load, .next and .menu still draw, because those were asked for. - private void ShowLibraryFor(ulong steamId, IReadOnlyList library) + // panel. Drawing whoever's fetch landed last put one player's private + // lineups in front of everyone, and refusing to draw with company left a + // full server with markers that never followed a save or an edit. So the + // layer draws everybody's at once and each player is sent only the + // lineups that are theirs to see -- see ApplyLibraryVisibility. + private void OnLibraryReplaced(ulong steamId, IReadOnlyCollection dropped) { - List connected = _system.ConnectedSteamIds(); + if (dropped.Count > 0) + { + ForgetDropped(steamId, dropped); + } + + RedrawLibrary(); + } + + // A lineup archived on the website while somebody here had it loaded. Left + // alone it stayed their Loaded lineup -- scored against, rethrown, and as + // the one being practised, the reason the rest of the map was hidden from + // them -- with no marker of its own left to explain any of it. + private void ForgetDropped(ulong steamId, IReadOnlyCollection dropped) + { + PracticeState state = _system.StateFor(steamId); + + bool loadedGone = state.Loaded != null && dropped.Contains(state.Loaded.client_id); + bool practisingGone = + state.Practising != null && dropped.Contains(state.Practising.client_id); + + if (!loadedGone && !practisingGone) + { + return; + } + + LineupRecord gone = (practisingGone ? state.Practising : state.Loaded)!; + + if (loadedGone) + { + state.Loaded = null; + } + + if (practisingGone) + { + state.Practising = null; + } + + string? at = + state.Index >= 0 && state.Index < state.Results.Count + ? state.Results[state.Index].client_id + : null; + + state.Results.RemoveAll(match => dropped.Contains(match.client_id)); + state.Index = at == null ? -1 : state.Results.FindIndex(match => match.client_id == at); + + _replay.ClearGhosts(steamId); + _replay.ClearSelectionFor(steamId); + ForgetSpot(steamId); - if (connected.Count != 1 || connected[0] != steamId) + IPlayer? player = _system.Find(steamId); + + if (player != null) + { + _replay.ApplyLibraryVisibility(player); + } + + Tell( + steamId, + $" {ChatColors.Yellow}{DrillUtility.Name(gone)} {ChatColors.Grey}was removed from your library" + ); + } + + // Rebuilds only when what it would draw has changed, so this is cheap to + // call from anything that might have moved the library. .clear is not + // undone by it: that hides the layer from the player who asked, rather + // than taking it away from the room. + private void RedrawLibrary() + { + if (_tornDown) { return; } - _replay.ShowLibrary(library); + _replay.ShowLibrary(SharedLibrary()); + } + + // Every connected player's library, one lineup per client id. Taken in + // turns rather than one library after another: the draw is capped, and a + // cap that ran out halfway through the first player's library would show + // the second player none of theirs. + private IReadOnlyList SharedLibrary() + { + List> libraries = _system + .ConnectedSteamIds() + .Select(steamId => _library.For(steamId)) + .ToList(); + + var seen = new HashSet(); + var shared = new List(); + int longest = libraries.Count == 0 ? 0 : libraries.Max(library => library.Count); + + for (int index = 0; index < longest; index++) + { + foreach (IReadOnlyList library in libraries) + { + if (index < library.Count && seen.Add(library[index].client_id)) + { + shared.Add(library[index]); + } + } + } + + return shared; } private LibraryLoad LibraryLoadFor(ulong steamId)