diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index d9ecb520..1bbc2913 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -100,7 +100,9 @@ public override void Load(bool hotReload) }); RegisterListener(() => _roster.MapEnded(DateTimeOffset.UtcNow)); - RegisterListener(_ => _roster.MapStarted(DateTimeOffset.UtcNow)); + RegisterListener(map => + _roster.MapStarted(DateTimeOffset.UtcNow, map) + ); RegisterListener(hibernating => { @@ -144,6 +146,20 @@ public override void Load(bool hotReload) Logger.LogDebug(error, "unable to read the players present on load"); } + // A hot reload has missed the map's load, and a cold boot has no map to + // ask about yet: it arrives with OnMapStart. + if (hotReload) + { + try + { + _roster.MapIs(Server.MapName); + } + catch (Exception error) + { + Logger.LogDebug(error, "unable to read the map on load"); + } + } + _loop.Start(); // Queued onto a world update by a thread timer: an empty server diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index c6a623dc..47bd7953 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -137,7 +137,7 @@ public override void Load(bool hotReload) _mapUnloadHandler = _ => _roster.MapEnded(DateTimeOffset.UtcNow); Core.Event.OnMapUnload += _mapUnloadHandler; - _mapLoadHandler = _ => _roster.MapStarted(DateTimeOffset.UtcNow); + _mapLoadHandler = @event => _roster.MapStarted(DateTimeOffset.UtcNow, @event.MapName); Core.Event.OnMapLoad += _mapLoadHandler; _worldUpdateHandler = OnWorldUpdate; @@ -174,6 +174,20 @@ public override void Load(bool hotReload) _logger.LogDebug(error, "unable to read the players present on load"); } + // A hot reload has missed the map's load, and a cold boot has no map to + // ask about yet: it arrives with OnMapLoad. + if (hotReload) + { + try + { + _roster.MapIs(Core.Engine.GlobalVars.MapName.ToString()); + } + catch (Exception error) + { + _logger.LogDebug(error, "unable to read the map on load"); + } + } + _loop.Start(); } diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 0775f7e9..404c96d6 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -102,6 +102,13 @@ public Dictionary Conns(int sync) .ToList(); } + public string? Map(int sync) + { + return Bodies[sync].TryGetProperty("map", out JsonElement map) + ? map.GetString() + : null; + } + public List DepartedConns(int sync) { return Bodies[sync] @@ -392,19 +399,21 @@ public async Task TheHoldEndsAsSoonAsEveryoneIsBack() Dictionary conns = panel.Conns(0); roster.MapEnded(At(1)); - await loop.Tick(Start + SanctionSyncLoop.Interval); + await loop.Tick(At(1)); + await loop.Tick(At(1) + SanctionSyncLoop.Interval); - Assert.Null(panel.Players(1)); - Assert.Empty(panel.Departed(1)); + Assert.Equal(["1", "2"], panel.SteamIds(1)); + Assert.Null(panel.Players(2)); + Assert.Empty(panel.Departed(2)); - roster.MapStarted(At(40)); + roster.MapStarted(At(40), "de_inferno"); loop.Observe([Human("1")], At(41)); loop.Observe([Human("1"), Human("2")], At(50)); await loop.Tick(At(50) + PlayerRoster.Debounce); - Assert.Equal(["1", "2"], panel.SteamIds(2)); - Assert.Equal(conns, panel.Conns(2)); - Assert.Empty(panel.Departed(2)); + Assert.Equal(["1", "2"], panel.SteamIds(3)); + Assert.Equal(conns, panel.Conns(3)); + Assert.Empty(panel.Departed(3)); } // The first tick of a new map routinely finds nobody back yet. Settling on @@ -419,7 +428,7 @@ public async Task AnEmptyFirstObservationDropsNobodyStillLoading() Dictionary conns = panel.Conns(0); roster.MapEnded(At(1)); - roster.MapStarted(At(2)); + roster.MapStarted(At(2), "de_inferno"); for (int second = 3; second <= 20; second++) { @@ -430,13 +439,15 @@ public async Task AnEmptyFirstObservationDropsNobodyStillLoading() loop.Observe([Human("1")], At(21)); loop.Observe([Human("1"), Human("2")], At(25)); await loop.Tick(At(25) + PlayerRoster.Debounce); - Assert.Single(panel.Bodies); - loop.Observe([Human("1"), Human("2")], Start + SanctionSyncLoop.Interval); - await loop.Tick(Start + SanctionSyncLoop.Interval); + // The map-end report, and nothing since. + Assert.Equal(2, panel.Bodies.Count); + + loop.Observe([Human("1"), Human("2")], At(3) + SanctionSyncLoop.Interval); + await loop.Tick(At(3) + SanctionSyncLoop.Interval); - Assert.Equal(conns, panel.Conns(1)); - Assert.Empty(panel.Departed(1)); + Assert.Equal(conns, panel.Conns(2)); + Assert.Empty(panel.Departed(2)); } [Fact] @@ -447,19 +458,20 @@ public async Task WhoeverHasNotComeBackWhenTheHoldSettlesHasLeft() await loop.Tick(Start); roster.MapEnded(At(1)); - roster.MapStarted(At(5)); + await loop.Tick(At(2)); + roster.MapStarted(At(5), "de_inferno"); loop.Observe([Human("1")], At(6)); DateTimeOffset settled = At(6) + PlayerRoster.Settle; loop.Observe([Human("1")], settled - TimeSpan.FromSeconds(1)); await loop.Tick(settled - TimeSpan.FromSeconds(1)); - Assert.Null(panel.Players(1)); + Assert.Null(panel.Players(2)); loop.Observe([Human("1")], settled); await loop.Tick(settled + PlayerRoster.Debounce); - Assert.Equal(["1"], panel.SteamIds(2)); - Assert.Equal([("2", 0, 0)], panel.Departed(2)); + Assert.Equal(["1"], panel.SteamIds(3)); + Assert.Equal([("2", 0, 0)], panel.Departed(3)); } [Fact] @@ -493,7 +505,7 @@ public async Task AHoldTheTicksNeverReturnToEndsEmpty() await loop.Tick(Start); roster.MapEnded(At(1)); - roster.MapStarted(At(2)); + roster.MapStarted(At(2), "de_inferno"); DateTimeOffset capped = At(1) + PlayerRoster.HoldCap; await loop.Tick(capped); @@ -611,8 +623,23 @@ public async Task DeparturesThePanelDidNotRecordAreSentAgain() Assert.Empty(panel.Departed(3)); } + // A map change leaves no sync without a roster that has departures to + // carry, so this one is made by hand. + [Fact] + public void ASyncWithoutARosterDoesNotSettleDepartures() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + roster.Observe([Human("1")], At(1)); + + RosterReport report = roster.Report(At(2)); + roster.Delivered(report with { Players = null }, true); + + Assert.Equal(["2"], roster.Report(At(3)).Departed.Select(player => player.steam_id)); + } + [Fact] - public async Task ASyncWithoutARosterDoesNotSettleDepartures() + public async Task TheMapEndReportCarriesAndSettlesTheDeparturesBeforeIt() { var (loop, roster, _, panel) = Loop(); loop.Observe([Human("1"), Human("2")], Start); @@ -620,24 +647,301 @@ public async Task ASyncWithoutARosterDoesNotSettleDepartures() loop.Observe([Human("1")], At(1)); roster.MapEnded(At(2)); - await loop.Tick(At(1) + PlayerRoster.Debounce); + await loop.Tick(At(2)); - Assert.Null(panel.Players(1)); + Assert.Equal(["1"], panel.SteamIds(1)); Assert.Equal([("2", 0, 0)], panel.Departed(1)); - roster.MapStarted(At(3)); + roster.MapStarted(At(3), "de_inferno"); loop.Observe([Human("1")], At(4)); - await loop.Tick(At(4) + PlayerRoster.Debounce); + loop.Observe([Human("1")], At(2) + SanctionSyncLoop.Interval); + await loop.Tick(At(2) + SanctionSyncLoop.Interval); - DateTimeOffset next = At(4) + PlayerRoster.Debounce + SanctionSyncLoop.Interval; + DateTimeOffset next = At(2) + SanctionSyncLoop.Interval * 2; loop.Observe([Human("1")], next); await loop.Tick(next); + // Everyone held is back, so the new map's roster is known at once. Assert.Equal(["1"], panel.SteamIds(2)); - Assert.Equal([("2", 0, 0)], panel.Departed(2)); + Assert.Equal(["1"], panel.SteamIds(3)); + Assert.Empty(panel.Departed(2)); Assert.Empty(panel.Departed(3)); } + // The panel splits a session's counts by the map each report names, so the + // kills between the last sync and the end of a map have to be reported as + // that map's before the next one starts. + [Fact] + public async Task AMapsLastKillsAreReportedOnThatMap() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs("de_mirage"); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + roster.Died("2", "1"); + roster.Died("2", "1"); + roster.MapEnded(At(5)); + roster.MapStarted(At(6), "de_inferno"); + await loop.Tick(At(6)); + + Assert.Equal("de_mirage", panel.Map(0)); + Assert.Equal("de_mirage", panel.Map(1)); + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 2, 0), + new Reported("2", "player 2", "203.0.113.24", 0, 2), + ], + panel.Players(1) + ); + + await loop.Tick(At(6) + SanctionSyncLoop.Interval); + Assert.Null(panel.Players(2)); + Assert.Equal("de_inferno", panel.Map(2)); + + loop.Observe([Human("1"), Human("2")], At(6) + SanctionSyncLoop.Interval * 2); + roster.Died("1", "2"); + await loop.Tick(At(6) + SanctionSyncLoop.Interval * 2); + + Assert.Equal("de_inferno", panel.Map(3)); + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 2, 1), + new Reported("2", "player 2", "203.0.113.24", 1, 2), + ], + panel.Players(3) + ); + } + + [Fact] + public async Task AMapEndReportThePanelMissedIsSentAgain() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs("de_mirage"); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + roster.Died(null, "1"); + roster.MapEnded(At(5)); + roster.MapStarted(At(6), "de_inferno"); + panel.Answer = () => Task.FromResult(Panel.Down()); + await loop.Tick(At(6)); + + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); + await loop.Tick(At(6) + SanctionSyncLoop.Interval); + await loop.Tick(At(6) + SanctionSyncLoop.Interval * 2); + + foreach (int sync in new[] { 1, 2 }) + { + Assert.Equal("de_mirage", panel.Map(sync)); + Assert.Equal( + [new Reported("1", "player 1", "203.0.113.24", 1, 0)], + panel.Players(sync) + ); + } + + Assert.Null(panel.Players(3)); + Assert.Equal("de_inferno", panel.Map(3)); + } + + // The sync in flight carries the roster from before the last kills, so the + // panel taking it must not stand in for the map-end report. + [Fact] + public async Task ASyncInFlightAtTheMapChangeIsNotTheMapEndReport() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs("de_mirage"); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + roster.Died("2", "1"); + + TaskCompletionSource answer = new(); + panel.Answer = () => answer.Task; + loop.Observe([Human("1"), Human("2")], Start + SanctionSyncLoop.Interval); + Task inFlight = loop.Tick(Start + SanctionSyncLoop.Interval); + + roster.Died("2", "1"); + roster.MapEnded(At(31)); + roster.MapStarted(At(32), "de_inferno"); + answer.SetResult(Panel.Ok(recorded: true)); + await inFlight; + + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); + await loop.Tick(At(32)); + + Assert.Equal("de_mirage", panel.Map(1)); + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 1, 0), + new Reported("2", "player 2", "203.0.113.24", 0, 1), + ], + panel.Players(1) + ); + + Assert.Equal("de_mirage", panel.Map(2)); + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 2, 0), + new Reported("2", "player 2", "203.0.113.24", 0, 2), + ], + panel.Players(2) + ); + + await loop.Tick(At(32) + SanctionSyncLoop.Interval); + + Assert.Equal(4, panel.Bodies.Count); + Assert.Equal("de_inferno", panel.Map(3)); + Assert.Null(panel.Players(3)); + } + + // A kill on the new map while its players are still loading in belongs to + // that map, even when the old map's report is sent after it. + [Fact] + public async Task AKillDuringTheHoldIsNotInTheMapEndReport() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs("de_mirage"); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + roster.Died("2", "1"); + roster.MapEnded(At(5)); + roster.MapStarted(At(6), "de_inferno"); + roster.Died("2", "1"); + + panel.Answer = () => Task.FromResult(Panel.Down()); + await loop.Tick(At(6)); + + roster.Died("2", "1"); + + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); + await loop.Tick(At(6) + SanctionSyncLoop.Interval); + + foreach (int sync in new[] { 1, 2 }) + { + Assert.Equal("de_mirage", panel.Map(sync)); + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 1, 0), + new Reported("2", "player 2", "203.0.113.24", 0, 1), + ], + panel.Players(sync) + ); + } + + loop.Observe([Human("1"), Human("2")], At(6) + SanctionSyncLoop.Interval * 2); + await loop.Tick(At(6) + SanctionSyncLoop.Interval * 2); + + Assert.Equal(4, panel.Bodies.Count); + Assert.Equal("de_inferno", panel.Map(3)); + Assert.Equal( + [ + new Reported("1", "player 1", "203.0.113.24", 3, 0), + new Reported("2", "player 2", "203.0.113.24", 0, 3), + ], + panel.Players(3) + ); + } + + // A roster that changed over the map change differs from the report still + // waiting to be taken; that must not turn every poll into a sync. + [Fact] + public async Task ARefusedMapEndReportIsOnlyRetriedOnTheInterval() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs("de_mirage"); + loop.Observe([Human("1"), Human("2")], Start); + await loop.Tick(Start); + + roster.MapEnded(At(5)); + roster.MapStarted(At(6), "de_inferno"); + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: false)); + + DateTimeOffset now = At(6); + + for (; now < At(80); now += TimeSpan.FromSeconds(0.5)) + { + loop.Observe([Human("1")], now); + await loop.Tick(now); + } + + // At 6, 36 and 66 seconds, though the hold settled at 52. + Assert.Equal(4, panel.Bodies.Count); + + for (int sync = 1; sync < 4; sync++) + { + Assert.Equal("de_mirage", panel.Map(sync)); + Assert.Equal(["1", "2"], panel.SteamIds(sync)); + } + + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); + + for (; now < At(100); now += TimeSpan.FromSeconds(0.5)) + { + loop.Observe([Human("1")], now); + await loop.Tick(now); + } + + Assert.Equal(6, panel.Bodies.Count); + Assert.Equal("de_mirage", panel.Map(4)); + Assert.Equal("de_inferno", panel.Map(5)); + Assert.Equal(["1"], panel.SteamIds(5)); + Assert.Equal([("2", 0, 0)], panel.Departed(5)); + } + + [Fact] + public async Task AMapEndReportIsGivenUpAfterTheCap() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs("de_mirage"); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + roster.MapEnded(At(5)); + roster.MapStarted(At(6), "de_inferno"); + panel.Answer = () => Task.FromResult(Panel.Down()); + + DateTimeOffset now = At(6); + + for (; now < At(5) + PlayerRoster.HoldCap; now += SanctionSyncLoop.Interval) + { + await loop.Tick(now); + Assert.Equal("de_mirage", panel.Map(panel.Bodies.Count - 1)); + } + + panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); + loop.Observe([Human("1")], now); + await loop.Tick(now); + + Assert.Equal("de_inferno", panel.Map(panel.Bodies.Count - 1)); + Assert.Equal(["1"], panel.SteamIds(panel.Bodies.Count - 1)); + } + + [Fact] + public async Task AnUnknownRosterHasNoMapEndReport() + { + var (loop, roster, _, panel) = Loop(); + roster.MapEnded(At(1)); + roster.MapStarted(At(2), "de_inferno"); + await loop.Tick(At(2)); + + Assert.Single(panel.Bodies); + Assert.Null(panel.Players(0)); + Assert.Equal("de_inferno", panel.Map(0)); + } + + [Fact] + public async Task ABlankMapIsNoMap() + { + var (loop, roster, _, panel) = Loop(); + roster.MapIs(" "); + loop.Observe([Human("1")], Start); + await loop.Tick(Start); + + Assert.Equal(JsonValueKind.Null, panel.Bodies[0].GetProperty("map").ValueKind); + } + [Fact] public async Task ADepartureWhileASyncIsInFlightWaitsForTheNext() { diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs index f6b04077..a1cbc8c0 100644 --- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs @@ -27,6 +27,10 @@ public class PlayerSanctionsRequest // way: the panel leaves its sessions alone for null and closes them for []. public List? players { get; set; } public List departed { get; set; } = new(); + + // The map the players' counts were made on. Ignored by panels that predate + // per-map sessions. + public string? map { get; set; } } // Kills and deaths are running totals for the connection named by conn, which diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index a85afc8e..9404a299 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -5,7 +5,11 @@ namespace FiveStack.Utilities; public sealed record ObservedPlayer(string SteamId, string Name, string? Ip, bool Verified); -public sealed record RosterReport(List? Players, List Departed); +public sealed record RosterReport( + List? Players, + List Departed, + string? Map +); // Who is on the server, as the game thread last saw it, never as the connect // and disconnect hooks tell it: a changelevel puts every client through a @@ -62,6 +66,9 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private DateTimeOffset _holdSince; private bool _mapStarted; private DateTimeOffset? _releaseAt; + private string? _map; + private RosterReport? _final; + private DateTimeOffset _finalAt; // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) @@ -126,26 +133,48 @@ public bool IsHibernating(DateTimeOffset now) } } + // The roster as the map ended goes out on its own, before the hold, so the + // panel credits the map's last kills to it and not to the next map. A map + // that ends before its own hold has settled has no roster to send, and a + // map change while the last map's report is still undelivered replaces + // it: either way those kills land on the next map. public void MapEnded(DateTimeOffset now) { lock (_lock) { + if (_state == eRosterState.Known) + { + _final = new RosterReport(Players(), Departures(), _map); + _finalAt = now; + _dueAt = now; + } + Hold(now); _mapStarted = false; _releaseAt = null; } } - public void MapStarted(DateTimeOffset now) + public void MapStarted(DateTimeOffset now, string? map) { lock (_lock) { + _map = MapName(map); Hold(now); _mapStarted = true; _releaseAt = null; } } + // For a plugin loaded onto a map already running, which never sees it start. + public void MapIs(string? map) + { + lock (_lock) + { + _map = MapName(map); + } + } + // Trusted even through a hold: the server only hibernates once nobody is // connected, and the ticks that would settle the hold have stopped. public void Hibernating(DateTimeOffset now) @@ -193,36 +222,20 @@ public virtual RosterReport Report(DateTimeOffset now) { Advance(now); - List? players = - _state == eRosterState.Known - ? _current - .Select(entry => new RosterPlayer - { - steam_id = entry.Key, - conn = entry.Value.Conn, - name = entry.Value.Name, - ip = entry.Value.Ip, - kills = entry.Value.Kills, - deaths = entry.Value.Deaths, - }) - .ToList() - : null; + _dueAt = null; + + if (_final != null) + { + _sent = _final.Players!.ToDictionary(player => player.conn, player => player.name); + + return _final; + } + + List? players = _state == eRosterState.Known ? Players() : null; _sent = players?.ToDictionary(player => player.conn, player => player.name); - _dueAt = null; - List departed = _departed - .Take(MaxDepartures) - .Select(departure => new DepartedPlayer - { - steam_id = departure.Player.steam_id, - conn = departure.Player.conn, - kills = departure.Player.kills, - deaths = departure.Player.deaths, - }) - .ToList(); - - return new RosterReport(players, departed); + return new RosterReport(players, Departures(), _map); } } @@ -231,7 +244,7 @@ public virtual RosterReport Report(DateTimeOffset now) // nothing predates the roster and has taken them. public void Delivered(RosterReport report, bool? recorded) { - if (report.Players == null || recorded == false || report.Departed.Count == 0) + if (report.Players == null || recorded == false) { return; } @@ -240,6 +253,11 @@ public void Delivered(RosterReport report, bool? recorded) lock (_lock) { + if (ReferenceEquals(report, _final)) + { + _final = null; + } + _departed.RemoveAll(departure => conns.Contains(departure.Player.conn)); } } @@ -303,6 +321,44 @@ bool teammatesAreEnemies return attackerSlot != victimSlot && (teammatesAreEnemies || attackerTeam != victimTeam); } + // The panel checks the name itself; this only keeps an empty one from + // reading as a map. + private static string? MapName(string? raw) + { + string map = (raw ?? "").Trim(); + + return map.Length == 0 ? null : map; + } + + private List Players() + { + return _current + .Select(entry => new RosterPlayer + { + steam_id = entry.Key, + conn = entry.Value.Conn, + name = entry.Value.Name, + ip = entry.Value.Ip, + kills = entry.Value.Kills, + deaths = entry.Value.Deaths, + }) + .ToList(); + } + + private List Departures() + { + return _departed + .Take(MaxDepartures) + .Select(departure => new DepartedPlayer + { + steam_id = departure.Player.steam_id, + conn = departure.Player.conn, + kills = departure.Player.kills, + deaths = departure.Player.deaths, + }) + .ToList(); + } + private void Hold(DateTimeOffset now) { if (_state != eRosterState.Hold) @@ -317,6 +373,13 @@ private void Advance(DateTimeOffset now) { _departed.RemoveAll(departure => now - departure.At > DepartedTtl); + // A panel that cannot take the final report for this long is not + // holding up every report after it. + if (_final != null && now - _finalAt >= HoldCap) + { + _final = null; + } + if (_state == eRosterState.Hold && Settled(now)) { _state = eRosterState.Known; @@ -334,7 +397,9 @@ private void Advance(DateTimeOffset now) Reconcile(now); } - if (!Matches()) + // A pending final report is what any sync would send, so a roster that + // differs from it waits for it to be delivered or given up. + if (_final == null && !Matches()) { _dueAt ??= now + Debounce; } diff --git a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs index d567ecc8..47d43ec3 100644 --- a/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs +++ b/shared/dotnet/FiveStack.Utilities/SanctionSyncLoop.cs @@ -170,6 +170,7 @@ public async Task Tick(DateTimeOffset now) hibernating = _hibernating(), players = roster.Players, departed = roster.Departed, + map = roster.Map, } );