diff --git a/apps/utility-sw/src/Commands/Practice.cs b/apps/utility-sw/src/Commands/Practice.cs index 2a42aca1..8463d6a3 100644 --- a/apps/utility-sw/src/Commands/Practice.cs +++ b/apps/utility-sw/src/Commands/Practice.cs @@ -134,6 +134,7 @@ private void SaveThrow(ulong steamId, string name) thrown.name = name; thrown.map = _library.Map; + thrown.workshop_map_id = Core.Engine.WorkshopId; thrown.side = player.Controller.Team == Team.CT ? "CT" : "TERRORIST"; thrown.visibility = nameof(eLineupVisibility.Private); thrown.plugin_version = ModuleVersion; @@ -177,26 +178,85 @@ private void SaveThrow(ulong steamId, string name) private void IngestSave(ulong steamId, LineupRecord thrown) { + string workshopId = thrown.workshop_map_id ?? Core.Engine.WorkshopId ?? ""; + _ = Task.Run(async () => { - string? id = await _api.Ingest(thrown); + UtilityApiClient.IngestResult result = await _api.Ingest(thrown, workshopId); Core.Scheduler.NextTick(() => { - if (id != null) + PracticeSaveUtility.GivePanelId(thrown, result.Id); + + if (result.Outcome == UtilityApiClient.eIngestOutcome.Held) { - thrown.id = id; - return; + Tell( + steamId, + $" {ChatColors.Red}{thrown.name} could not reach the panel; it will retry" + ); + } + else if (result.Outcome == UtilityApiClient.eIngestOutcome.Refused) + { + Tell(steamId, $" {ChatColors.Red}{thrown.name} was not saved"); + } + else if ( + result.Outcome == UtilityApiClient.eIngestOutcome.Delivered + && PracticeSaveUtility.BelongsBackInLibrary( + thrown, + _library.Map, + _library.For(steamId) + ) + ) + { + _library.Add(steamId, thrown); } - - Tell( - steamId, - $" {ChatColors.Red}{thrown.name} could not reach the panel; it will retry" - ); }); }); } + private void OnSaveRetried(LineupRecord saved, UtilityApiClient.IngestResult result) + { + Core.Scheduler.NextTick(() => + { + PracticeSaveUtility.GivePanelId(saved, result.Id); + + if (!ulong.TryParse(saved.author_steam_id, out ulong steamId)) + { + return; + } + + if (result.Outcome == UtilityApiClient.eIngestOutcome.Refused) + { + Tell(steamId, $" {ChatColors.Red}{result.Name} was not saved"); + return; + } + + if (PracticeSaveUtility.BelongsBackInLibrary(saved, _library.Map, _library.For(steamId))) + { + _library.Add(steamId, saved); + } + + Tell(steamId, $" {ChatColors.Green}{result.Name} reached the panel"); + }); + } + + private void OnWithdrawnDeleteFailed(string author, string name) + { + if (ulong.TryParse(author, out ulong steamId)) + { + Core.Scheduler.NextTick(() => TellStillOnPanel(steamId, name)); + } + } + + private void TellStillOnPanel(ulong steamId, string name) + { + Tell( + steamId, + $" {ChatColors.Red}{name} is still on the panel; " + + $"try {ChatColors.Default}TAB ▸ Settings ▸ Reload Library" + ); + } + // Leaving does not take a save with it. What was saved goes now, without // a cloud that may not finish before an empty server stops ticking. private void SendHeldSaves(ulong steamId) @@ -1120,37 +1180,40 @@ private void DeleteLoaded(IPlayer player) return; } - _library.Remove(player.SteamID, loaded); - state.Results.RemoveAll(match => match.client_id == loaded.client_id); + Predicate isLoaded = PracticeSaveUtility.SameLineup(loaded); + + _library.Remove(player.SteamID, isLoaded); + state.Index = PracticeSaveUtility.PruneResults(state.Results, state.Index, isLoaded); state.Loaded = null; _replay.ClearGhosts(player.SteamID); Toast(player, $"Deleted · {DrillUtility.Name(loaded)}"); - if (loaded.id == null) + string? id = PracticeSaveUtility.ForgetSaves( + loaded, + save => _heldSaves.Remove(save), + _api.Withdraw + ); + + _drill.LineupsRemoved(player.SteamID, isLoaded); + + if (id == null) { return; } - string id = loaded.id; ulong steamId = player.SteamID; _ = Task.Run(async () => { - bool deleted = await _api.Delete(id); + bool deleted = await _api.Delete(id, steamId.ToString()); if (deleted) { return; } - Core.Scheduler.NextTick(() => - Tell( - steamId, - $" {ChatColors.Red}{loaded.name} is still on the panel; " - + $"try {ChatColors.Default}TAB ▸ Settings ▸ Reload Library" - ) - ); + Core.Scheduler.NextTick(() => TellStillOnPanel(steamId, loaded.name)); }); } diff --git a/apps/utility-sw/src/Commands/Solver.cs b/apps/utility-sw/src/Commands/Solver.cs index 2c757f4a..c84d05b9 100644 --- a/apps/utility-sw/src/Commands/Solver.cs +++ b/apps/utility-sw/src/Commands/Solver.cs @@ -297,24 +297,7 @@ private void Finished( // The same hand-off .save makes, so a solved lineup reaches the panel by // the one path that already retries. - _ = Task.Run(async () => - { - string? id = await _api.Ingest(lineup); - - Core.Scheduler.NextTick(() => - { - if (id != null) - { - lineup.id = id; - return; - } - - Tell( - steamId, - $" {ChatColors.Red}{lineup.name} could not reach the panel; it will retry" - ); - }); - }); + IngestSave(steamId, lineup); } private void Refuse(ICommandContext context, CalibrationReport report) diff --git a/apps/utility-sw/src/Services/PracticeDrill.cs b/apps/utility-sw/src/Services/PracticeDrill.cs index 8472af26..ec12f325 100644 --- a/apps/utility-sw/src/Services/PracticeDrill.cs +++ b/apps/utility-sw/src/Services/PracticeDrill.cs @@ -144,6 +144,49 @@ public bool Stop(ulong steamId) return true; } + // A step that cannot be scored ignores every throw, so a lineup the player + // deleted has to leave the run or the run stalls with no word why. + public void LineupDeleted(ulong steamId, string clientId) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run)) + { + return; + } + + bool wasCurrent = run.Remove(clientId); + + if (run.Length == 0) + { + run.End(eDrillEnd.Stopped); + Finish(steamId, run); + return; + } + + if (wasCurrent) + { + Advance(steamId, run); + } + } + + public void LineupsRemoved(ulong steamId, Predicate gone) + { + if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run)) + { + return; + } + + List removed = run + .Queue.Where(row => gone(row)) + .Select(row => row.client_id) + .Distinct() + .ToList(); + + foreach (string clientId in removed) + { + LineupDeleted(steamId, clientId); + } + } + public bool Skip(ulong steamId) { if (!_runs.TryGetValue(steamId, out PracticeDrillRun? run) || !run.Skip()) diff --git a/apps/utility-sw/src/Services/PracticeLibrary.cs b/apps/utility-sw/src/Services/PracticeLibrary.cs index 8cc6ee3a..926c15c8 100644 --- a/apps/utility-sw/src/Services/PracticeLibrary.cs +++ b/apps/utility-sw/src/Services/PracticeLibrary.cs @@ -84,11 +84,11 @@ public void Add(ulong steamId, LineupRecord lineup) lineups.Add(lineup); } - public void Remove(ulong steamId, LineupRecord lineup) + public void Remove(ulong steamId, Predicate gone) { if (_lineups.TryGetValue(steamId, out List? lineups)) { - lineups.RemoveAll(existing => existing.client_id == lineup.client_id); + lineups.RemoveAll(gone); } } diff --git a/apps/utility-sw/src/Services/PracticeRecorder.cs b/apps/utility-sw/src/Services/PracticeRecorder.cs index 2d2e0ad2..066b9335 100644 --- a/apps/utility-sw/src/Services/PracticeRecorder.cs +++ b/apps/utility-sw/src/Services/PracticeRecorder.cs @@ -163,6 +163,9 @@ public void Forget(uint entityIndex) public void Reset() { + // A throw belongs to the map it was thrown on, and .save stamps whatever + // map the server is on now. + _history.Clear(); _armed.Clear(); _stationary.Clear(); _settling.Clear(); diff --git a/apps/utility-sw/src/Services/PracticeSession.cs b/apps/utility-sw/src/Services/PracticeSession.cs index 49b46821..2f3cd20b 100644 --- a/apps/utility-sw/src/Services/PracticeSession.cs +++ b/apps/utility-sw/src/Services/PracticeSession.cs @@ -36,6 +36,9 @@ ILogger logger // a changelevel takes. public string? Map { get; set; } + // Read on the game thread with Map: this class fetches off it. + public string? WorkshopId { get; set; } + private int _refreshing; private DateTime _lastAttempt = DateTime.MinValue; @@ -92,7 +95,7 @@ public async Task Refresh() return; } - PracticeSessionData? session = await _api.Session(Map, relay); + PracticeSessionData? session = await _api.Session(Map, WorkshopId, relay); // A failed fetch must not empty the roster: everyone already connected // stays connected, and the door keeps the policy it had. diff --git a/apps/utility-sw/src/Services/UtilityApiClient.cs b/apps/utility-sw/src/Services/UtilityApiClient.cs index 2eeb3e8f..d420609f 100644 --- a/apps/utility-sw/src/Services/UtilityApiClient.cs +++ b/apps/utility-sw/src/Services/UtilityApiClient.cs @@ -21,16 +21,25 @@ public class UtilityApiClient // an offline practice server left running overnight must not grow forever. private const int MaxQueued = 64; + private const int RetrySeconds = 10; + + // About half an hour of retries on a server that is ticking. Counted in + // attempts so a hibernating server does not use them up, and a head that is + // still refused by then is holding up everything behind it. + public const int MaxAttempts = 180; + + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); private readonly UtilityConfig _config; private readonly ILogger _logger; private readonly object _queueLock = new object(); - private readonly Queue _retryQueue = new Queue(); - private readonly Queue _resultQueue = - new Queue(); + private readonly Queue _retryQueue = new Queue(); + private readonly Queue _resultQueue = new Queue(); + private readonly List _firstSends = new List(); private readonly SemaphoreSlim _draining = new SemaphoreSlim(1, 1); + private int _secondsHeld; public UtilityApiClient(UtilityConfig config, ILogger logger) { @@ -38,6 +47,32 @@ public UtilityApiClient(UtilityConfig config, ILogger logger) _logger = logger; } + // One save as it stood when it was made. A throw saved twice is two lineups + // sharing one record, so nothing a retry sends can be read back off that + // record, and client_id is minted here: per save, and kept by its retries. + private sealed class QueuedSave + { + public required LineupRecord Record { get; init; } + public required string Body { get; init; } + public required string Map { get; init; } + public required string Name { get; init; } + public required string Author { get; init; } + public int Attempts { get; set; } + public bool Withdrawn { get; set; } + + // Left this server at least once, so it may be on the panel. + public bool Sent { get; set; } + } + + private sealed class QueuedResult + { + public required UtilityPracticeResultPayload Payload { get; init; } + public int Attempts { get; set; } + } + + // A save from another map stops here. Until this is known nothing is sent. + public string Map { get; set; } = ""; + private class IngestResponse { public string? id { get; set; } @@ -54,20 +89,103 @@ public int QueuedCount } } - public async Task Ingest(LineupRecord record) + public enum eIngestOutcome + { + Delivered, + Held, + Refused, + Withdrawn, + } + + public sealed class IngestResult + { + public eIngestOutcome Outcome { get; init; } + public string? Id { get; init; } + public string Name { get; init; } = ""; + + // The panel said no, which it only does to a save it does not have. + public bool PanelRefused { get; init; } + } + + // Raised off the game thread with the author and the name, for a save the + // player deleted that the panel kept all the same. + public event Action? WithdrawnDeleteFailed; + + // Raised off the game thread, for a held save that got its answer on a + // retry: whoever saved it has stopped waiting, and its panel id is only + // here. + public event Action? SaveRetried; + + // Called once a second from the game thread. A player told a save will + // retry is owed one, and no later save or map load is sure to come along. + public Task Second() { - string? id = await Post(record); + if (QueuedCount == 0) + { + _secondsHeld = 0; + return Task.CompletedTask; + } - if (id == null) + if (++_secondsHeld < RetrySeconds) { - Enqueue(record); - return null; + return Task.CompletedTask; } - // The panel is reachable again, so anything held back can go now. - _ = Drain(); + _secondsHeld = 0; - return id; + return Task.Run(Drain); + } + + public async Task Ingest(LineupRecord record, string? workshopId = null) + { + QueuedSave? sending = Snapshot(record, workshopId); + + if (sending == null) + { + return new IngestResult { Outcome = eIngestOutcome.Refused, Name = record.name }; + } + + lock (_queueLock) + { + _firstSends.Add(sending); + } + + IngestResult result = await Post(sending); + QueuedSave? evicted = null; + + lock (_queueLock) + { + _firstSends.Remove(sending); + + if (result.Outcome == eIngestOutcome.Held) + { + evicted = Enqueue(sending); + } + } + + if (evicted != null) + { + ReportEvicted(evicted); + } + + if (sending.Withdrawn) + { + // Held, it is on the queue now, and that is where it gets settled. + if (result.Outcome != eIngestOutcome.Held) + { + await DeleteWithdrawn(sending, result); + } + + return Answer(sending, eIngestOutcome.Withdrawn); + } + + if (result.Outcome == eIngestOutcome.Delivered) + { + // The panel is reachable again, so anything held back can go now. + _ = Drain(); + } + + return result; } // The executes a player can load from the menu: theirs and their team's, @@ -275,7 +393,11 @@ public async Task Callouts(string map, IReadOnlyCollection ca // The panel calls this server up the moment it asks for its session, and // hands out a connect string straight after. So where it can be reached // goes with the question, not on the next occupancy report. - public async Task Session(string? map, SteamRelayReport relay) + public async Task Session( + string? map, + string? workshopId, + SteamRelayReport relay + ) { // Send answers null without a word when it is not configured, which // would read as a failed fetch with no reason given. @@ -287,6 +409,10 @@ public async Task Callouts(string map, IReadOnlyCollection ca var query = new List(); + // Always, and empty on an official map: a plugin that sends no key at all + // is one the panel leaves alone, because it cannot say which level it is on. + query.Add($"workshop_id={Uri.EscapeDataString(workshopId ?? "")}"); + if (!string.IsNullOrEmpty(map)) { query.Add($"map={Uri.EscapeDataString(map)}"); @@ -398,10 +524,60 @@ public async Task DiscoveryDone(string id, string? error) != null; } - public async Task Delete(string id) + public async Task Delete(string id, string steamId) { - return await SendText(HttpMethod.Delete, $"/utility/{Uri.EscapeDataString(id)}", null) - != null; + string route = + $"/utility/{Uri.EscapeDataString(id)}?steam_id={Uri.EscapeDataString(steamId)}"; + + return await SendText(HttpMethod.Delete, route, null) != null; + } + + // A send that lost its answer may have landed, so a withdrawn save that + // leaves without a word from the panel may still be there. Only the panel + // turning it down says it is not. + private async Task DeleteWithdrawn(QueuedSave save, IngestResult sent) + { + if (sent.PanelRefused || !save.Sent) + { + return; + } + + if (sent.Id == null || !await Delete(sent.Id, save.Author)) + { + WithdrawnDeleteFailed?.Invoke(save.Author, save.Name); + } + } + + private void ReportEvicted(QueuedSave evicted) + { + if (evicted.Withdrawn) + { + if (evicted.Sent) + { + WithdrawnDeleteFailed?.Invoke(evicted.Author, evicted.Name); + } + + return; + } + + SaveRetried?.Invoke(evicted.Record, Answer(evicted, eIngestOutcome.Refused)); + } + + // A player deleted a save the panel has not answered for. It still goes + // out: a send that lost its answer may have landed, and sending it again is + // how to learn the id that has to be deleted. + public void Withdraw(string clientId) + { + lock (_queueLock) + { + foreach (QueuedSave queued in _firstSends.Concat(_retryQueue)) + { + if (queued.Record.client_id == clientId) + { + queued.Withdrawn = true; + } + } + } } // Retries oldest first: a player's saves replay in the order they threw @@ -417,32 +593,47 @@ public async Task Drain() { while (true) { - LineupRecord? record; + QueuedSave? queued; lock (_queueLock) { - if (!_retryQueue.TryPeek(out record)) + if (!_retryQueue.TryPeek(out queued)) { break; } } - if (await Post(record) == null) + IngestResult sent = await Post(queued); + + if (sent.Outcome == eIngestOutcome.Held && ++queued.Attempts < MaxAttempts) { return; } - lock (_queueLock) + // Delivered, refused outright or given up on -- either way it + // leaves the queue, so one bad save cannot hold up the ones + // behind it. + Settle(_retryQueue, queued); + + if (queued.Withdrawn) { - _retryQueue.TryDequeue(out _); + await DeleteWithdrawn(queued, sent); + continue; } + + SaveRetried?.Invoke( + queued.Record, + sent.Outcome == eIngestOutcome.Held + ? Answer(queued, eIngestOutcome.Refused) + : sent + ); } while (true) { - UtilityPracticeResultPayload? result; + QueuedResult? queued; lock (_queueLock) { - if (!_resultQueue.TryPeek(out result)) + if (!_resultQueue.TryPeek(out queued)) { return; } @@ -450,17 +641,19 @@ public async Task Drain() var outcome = new SendOutcome(); - if (await PostResult(result, outcome) == null && !outcome.Rejected) + if ( + await PostResult(queued.Payload, outcome) == null + && !outcome.Rejected + && ++queued.Attempts < MaxAttempts + ) { return; } - // Delivered, or refused outright -- either way it leaves the - // queue, so one bad payload cannot hold up the ones behind it. - lock (_queueLock) - { - _resultQueue.TryDequeue(out _); - } + // Delivered, refused outright or given up on -- either way it + // leaves the queue, so one bad payload cannot hold up the ones + // behind it. + Settle(_resultQueue, queued); } } finally @@ -469,16 +662,36 @@ public async Task Drain() } } - private void Enqueue(LineupRecord record) + // By reference: Enqueue may have evicted this one while it was being sent, + // and the head now is something that has not been. + private void Settle(Queue queue, T queued) + where T : class { lock (_queueLock) { - while (_retryQueue.Count >= MaxQueued) + if (queue.TryPeek(out T? head) && ReferenceEquals(head, queued)) { - _retryQueue.TryDequeue(out _); + queue.Dequeue(); } + } + } - _retryQueue.Enqueue(record); + // Hands back what had to go to make room. The player was told that one + // would retry, and the caller tells them otherwise outside the lock. + private QueuedSave? Enqueue(QueuedSave queued) + { + lock (_queueLock) + { + QueuedSave? evicted = null; + + if (_retryQueue.Count >= MaxQueued) + { + _retryQueue.TryDequeue(out evicted); + } + + _retryQueue.Enqueue(queued); + + return evicted; } } @@ -491,7 +704,7 @@ private void EnqueueResult(UtilityPracticeResultPayload payload) _resultQueue.TryDequeue(out _); } - _resultQueue.Enqueue(payload); + _resultQueue.Enqueue(new QueuedResult { Payload = payload }); } } @@ -610,16 +823,23 @@ private static bool Flag(string json, string property) } } - private async Task Post(LineupRecord record) + private QueuedSave? Snapshot(LineupRecord record, string? workshopId) { - string? body; - try { - body = JsonSerializer.Serialize( - UtilityIngestPayload.From(record), - PracticeJson.Options - ); + UtilityIngestPayload payload = UtilityIngestPayload.From(record); + payload.client_id = Guid.NewGuid().ToString(); + payload.map = record.map; + payload.workshop_id = string.IsNullOrEmpty(workshopId) ? null : workshopId; + + return new QueuedSave + { + Record = record, + Body = JsonSerializer.Serialize(payload, PracticeJson.Options), + Map = record.map, + Name = record.name, + Author = record.author_steam_id, + }; } catch (Exception error) { @@ -628,22 +848,66 @@ private static bool Flag(string json, string property) _logger.LogError(error, "unable to serialize lineup {client_id}", record.client_id); return null; } + } + + private static IngestResult Answer(QueuedSave save, eIngestOutcome outcome, string? id = null) + { + return new IngestResult { Outcome = outcome, Id = id, Name = save.Name }; + } - string? response = await SendText(HttpMethod.Post, "/utility/ingest", body); + private async Task Post(QueuedSave save) + { + if (string.IsNullOrEmpty(Map)) + { + return Answer(save, eIngestOutcome.Held); + } + + // One already sent may have landed, and only the panel can say: it answers + // a save it has before it looks at the map. Refusing here would tell a + // player a saved lineup was not saved. + if (!save.Sent && save.Map != Map) + { + _logger.LogWarning( + "not sending lineup {client_id}: saved on {saved}, the server is on {map}", + save.Record.client_id, + save.Map, + Map + ); + return Answer(save, eIngestOutcome.Refused); + } + + save.Sent = true; + + var outcome = new SendOutcome(); + string? response = await SendText(HttpMethod.Post, "/utility/ingest", save.Body, outcome); if (response == null) { - return null; + return outcome.Rejected + ? new IngestResult + { + Outcome = eIngestOutcome.Refused, + Name = save.Name, + PanelRefused = true, + } + : Answer(save, eIngestOutcome.Held); } try { - return JsonSerializer.Deserialize(response, PracticeJson.Options)?.id; + return Answer( + save, + eIngestOutcome.Delivered, + JsonSerializer.Deserialize(response, PracticeJson.Options)?.id + ); } catch (Exception error) { + // The panel answered, so it has the lineup. Only the id is missing, + // which is not worth a send that a panel without client_id would + // save a second time. _logger.LogError(error, "unable to read the ingest response"); - return null; + return Answer(save, eIngestOutcome.Delivered); } } @@ -663,6 +927,12 @@ private static bool Flag(string json, string property) catch (Exception error) { _logger.LogError(error, "unable to serialize a practice result"); + + if (outcome != null) + { + outcome.Rejected = true; + } + return null; } diff --git a/apps/utility-sw/src/UtilityPractice.csproj b/apps/utility-sw/src/UtilityPractice.csproj index 624ffad5..d83e34cb 100644 --- a/apps/utility-sw/src/UtilityPractice.csproj +++ b/apps/utility-sw/src/UtilityPractice.csproj @@ -68,6 +68,7 @@ + diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 4f8ba34b..c332cea1 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -194,6 +194,8 @@ public override void Load(bool hotReload) _drill.Finished = OnDrillFinished; _drill.Shown = UseHud; _api.LibraryProgress += OnLibraryProgress; + _api.SaveRetried += OnSaveRetried; + _api.WithdrawnDeleteFailed += OnWithdrawnDeleteFailed; WirePlaybook(); WireDrill(); @@ -332,7 +334,9 @@ public override void Load(bool hotReload) if (hotReload) { string current = Core.Engine.GlobalVars.MapName.ToString(); + _api.Map = current; _library.SetMap(current); + _session.WorkshopId = Core.Engine.WorkshopId; _session.Map = current; ApplyPracticeCfg(); RefreshEverything(); @@ -461,6 +465,8 @@ private void TearDown() _drill.Finished = null; _drill.Shown = null; _api.LibraryProgress -= OnLibraryProgress; + _api.SaveRetried -= OnSaveRetried; + _api.WithdrawnDeleteFailed -= OnWithdrawnDeleteFailed; if (_hud != null) { _hud.Clicked -= OnHudClicked; @@ -1660,7 +1666,7 @@ private string Title(IPlayer player, LineupRecord lineup) return name; } - if (state.Results[state.Index].client_id != lineup.client_id) + if (!PracticeSaveUtility.SameLineup(lineup)(state.Results[state.Index])) { return name; } @@ -1921,6 +1927,7 @@ private void OnSecond() DrainLibraryLoads(); DrainPendingMapLoad(); KeepWorldsCurrent(); + _ = _api.Second(); } // Nobody stays dead on a practice server. Rejoining while dead, falling off @@ -2204,6 +2211,9 @@ private void ForPlayer(int playerId, Action action) private void OnMapLoad(string mapName) { + // First: settling the old map's saves below must see the new one. + _api.Map = mapName; + // A take does not survive the level it was staged on: its camera and // its thrower's pawn went with the map. _director.Reset(null); @@ -2219,8 +2229,10 @@ private void OnMapLoad(string mapName) ForgetStacks(); ForgetEveryVerdict(); _mapChoices.Clear(); + _earlier.Clear(); _flashes.Clear(); - // A save held for a smoke on the old map goes now, without it. + // A save held for a smoke on the old map stops now: the panel has moved + // on, and would file it under this one. _voxels.Reset(); _recorder.Reset(); _playbook.Reset(); @@ -2238,6 +2250,7 @@ private void OnMapLoad(string mapName) _replay.SweepMarkers(); _library.SetMap(mapName); + _session.WorkshopId = Core.Engine.WorkshopId; _session.Map = mapName; _callouts.MapLoaded(); @@ -2336,13 +2349,13 @@ private void LibraryLanded(ulong steamId, string map, int count, bool pushed) // stepped through until the player ran a search. If they can SEE // them, they can walk them. // - // Only when the panel pushed, or when there is nothing to lose: the - // drain retries at a moment nobody chose, and replacing the results - // of a .load or a target picked off the minimap turns somebody's - // walk into the whole map halfway through it. + // Only when there is nothing to lose: a refresh comes at a moment + // nobody chose, a panel push for somebody else's delete included, and + // replacing the results of a .load or a target picked off the minimap + // turns somebody's walk into the whole map halfway through it. PracticeState state = _system.StateFor(steamId); - if (pushed || state.Results.Count == 0) + if (state.Results.Count == 0) { state.Results.Clear(); state.Results.AddRange(library); @@ -2383,25 +2396,27 @@ private void DropRemoved(ulong steamId, IReadOnlyList library) PracticeState state = _system.StateFor(steamId); - state.Results.RemoveAll(Gone); + state.Index = PracticeSaveUtility.PruneResults(state.Results, state.Index, Gone); + PracticeSaveUtility.RefreshResults(state.Results, library); - if (state.Loaded == null || !Gone(state.Loaded)) + if (state.Loaded != null && Gone(state.Loaded)) { - return; - } + LineupRecord gone = state.Loaded; - LineupRecord gone = state.Loaded; + state.Loaded = null; + state.Index = -1; + _replay.ClearGhosts(steamId); - state.Loaded = null; - state.Index = -1; - _replay.ClearGhosts(steamId); - - IPlayer? player = _system.Find(steamId); + IPlayer? player = _system.Find(steamId); - if (player != null && player.IsValid) - { - Toast(player, $"{PracticeLineupUtility.TitleCase(DrillUtility.Name(gone))} was removed on the panel"); + if (player != null && player.IsValid) + { + Toast(player, $"{PracticeLineupUtility.TitleCase(DrillUtility.Name(gone))} was removed on the panel"); + } } + + // Last: moving a drill on loads its next step. + _drill.LineupsRemoved(steamId, Gone); } // A player's markers are their own, drawn from their library after their diff --git a/apps/utility-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj index dcebc6ff..ca45b5a7 100644 --- a/apps/utility-sw/test/FiveStack.Tests.csproj +++ b/apps/utility-sw/test/FiveStack.Tests.csproj @@ -7,6 +7,7 @@ +