From ebe260514543415d5a1dd56939f579e2d80f1f36 Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 30 Oct 2026 00:06:18 +0100 Subject: [PATCH 01/13] feature: community servers report each map's final counts and name the map, so the panel can keep a map's last kills on that map The roster sends the counts it had when a map ended in a sync of its own, before it holds through the map change, and every sync names the map. A map-end report the panel misses is sent again, and given up after the hold cap so it cannot hold up the reports after it. Panels that predate per-map sessions ignore the map. --- .../src/PlayerManagementPlugin.cs | 5 +- .../src/PlayerManagementPlugin.cs | 4 +- .../test/PlayerRosterTests.cs | 208 +++++++++++++++--- .../PlayerManagement/PlayerSanction.cs | 4 + .../FiveStack.Utilities/PlayerRoster.cs | 120 +++++++--- .../FiveStack.Utilities/SanctionSyncLoop.cs | 1 + 6 files changed, 285 insertions(+), 57 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index d9ecb520..038c6d7f 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 => { @@ -138,6 +140,7 @@ public override void Load(bool hotReload) try { _loop.Observe(Observed(Humans()), DateTimeOffset.UtcNow); + _roster.MapIs(Server.MapName); } catch (Exception error) { diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index c6a623dc..c640e8ca 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -137,7 +137,8 @@ public override void Load(bool hotReload) _mapUnloadHandler = _ => _roster.MapEnded(DateTimeOffset.UtcNow); Core.Event.OnMapUnload += _mapUnloadHandler; - _mapLoadHandler = _ => _roster.MapStarted(DateTimeOffset.UtcNow); + _mapLoadHandler = _ => + _roster.MapStarted(DateTimeOffset.UtcNow, Core.Engine.GlobalVars.MapName.ToString()); Core.Event.OnMapLoad += _mapLoadHandler; _worldUpdateHandler = OnWorldUpdate; @@ -168,6 +169,7 @@ public override void Load(bool hotReload) try { _loop.Observe(Observed(Humans()), DateTimeOffset.UtcNow); + _roster.MapIs(Core.Engine.GlobalVars.MapName.ToString()); } catch (Exception error) { diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 0775f7e9..978b0c1f 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 async Task ASyncWithoutARosterDoesNotSettleDepartures() + 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 TheMapEndReportCarriesAndSettlesTheDeparturesBeforeIt() { var (loop, roster, _, panel) = Loop(); loop.Observe([Human("1"), Human("2")], Start); @@ -620,24 +647,155 @@ 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)); + } + + [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..60fd3845 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,45 @@ 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. 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 +219,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 +241,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 +250,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 +318,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 +370,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; 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, } ); From 1b0d154e399a1b4aed45ad0732b565a4de89c52b Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 30 Oct 2026 00:13:11 +0100 Subject: [PATCH 02/13] bug: a map-end report the panel refuses is retried on the interval, and the map is read from the map load event A roster that changed over the map change differed from the pending report on every poll, so each poll became a sync until the report was taken. The map name now comes with SwiftlyS2's map load event, and a plugin only asks the engine for it on a hot reload, where it has missed that event and the engine has a map to answer with. --- .../src/PlayerManagementPlugin.cs | 15 +++++- .../src/PlayerManagementPlugin.cs | 18 ++++++-- .../test/PlayerRosterTests.cs | 46 +++++++++++++++++++ .../FiveStack.Utilities/PlayerRoster.cs | 9 +++- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 038c6d7f..1bbc2913 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -140,13 +140,26 @@ public override void Load(bool hotReload) try { _loop.Observe(Observed(Humans()), DateTimeOffset.UtcNow); - _roster.MapIs(Server.MapName); } catch (Exception error) { 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 c640e8ca..47bd7953 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -137,8 +137,7 @@ public override void Load(bool hotReload) _mapUnloadHandler = _ => _roster.MapEnded(DateTimeOffset.UtcNow); Core.Event.OnMapUnload += _mapUnloadHandler; - _mapLoadHandler = _ => - _roster.MapStarted(DateTimeOffset.UtcNow, Core.Engine.GlobalVars.MapName.ToString()); + _mapLoadHandler = @event => _roster.MapStarted(DateTimeOffset.UtcNow, @event.MapName); Core.Event.OnMapLoad += _mapLoadHandler; _worldUpdateHandler = OnWorldUpdate; @@ -169,13 +168,26 @@ public override void Load(bool hotReload) try { _loop.Observe(Observed(Humans()), DateTimeOffset.UtcNow); - _roster.MapIs(Core.Engine.GlobalVars.MapName.ToString()); } catch (Exception error) { _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 978b0c1f..cf667337 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -744,6 +744,52 @@ [new Reported("1", "player 1", "203.0.113.24", 1, 0)], Assert.Equal("de_inferno", panel.Map(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() { diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 60fd3845..9404a299 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -134,7 +134,10 @@ 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. + // 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) @@ -394,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; } From 67c1452cd7ce4e7b31022d42ee22a17cd6367a04 Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 30 Oct 2026 05:03:13 +0100 Subject: [PATCH 03/13] feature: Player Management counts assists, headshots, damage, rounds and MVPs for the community stats Each connection now keeps running totals of assists, headshots, damage, rounds played, rounds won and MVPs next to its kills and deaths, and sends them on the roster and with every departure. Flash assists, team damage and warmup rounds do not count, a headshot only counts with a counted kill, and a lethal hit is credited only with the health the victim had left. Panels that predate the counters ignore them. --- .../src/PlayerManagementPlugin.cs | 164 +++++++- .../src/PlayerManagementPlugin.cs | 163 +++++++- .../test/PlayerRosterTests.cs | 388 +++++++++++++++++- .../PlayerManagement/PlayerSanction.cs | 21 +- .../FiveStack.Utilities/PlayerRoster.cs | 147 ++++++- 5 files changed, 873 insertions(+), 10 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 1bbc2913..2ff7675d 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -45,6 +45,10 @@ public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig _mutedByUs = new(); private readonly HashSet _kicked = new(); + // Each player's health after the last hit they survived, by slot, so a + // lethal hit is credited only with what it took. Cleared when they spawn. + private readonly Dictionary _health = new(); + private long _lastEnforceMs; private INetworkServerService? _networkServerService; @@ -401,6 +405,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) } CCSPlayerController? attacker = @event.Attacker; + CCSPlayerController? assister = @event.Assister; + bool teammatesAreEnemies = TeammatesAreEnemies(); bool kill = attacker != null @@ -410,10 +416,27 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) victim.Slot, attacker.TeamNum, victim.TeamNum, - TeammatesAreEnemies() + teammatesAreEnemies ); - _roster.Died(VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null); + bool assist = + assister != null + && IsHuman(assister) + && PlayerRoster.CountsAsAssist( + assister.Slot, + victim.Slot, + assister.TeamNum, + victim.TeamNum, + teammatesAreEnemies, + @event.Assistedflash + ); + + _roster.Died( + VerifiedSteamIdOf(victim), + kill ? VerifiedSteamIdOf(attacker!) : null, + assist ? VerifiedSteamIdOf(assister!) : null, + @event.Headshot + ); } catch (Exception error) { @@ -423,6 +446,126 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) return HookResult.Continue; } + [GameEventHandler] + public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) + { + try + { + CCSPlayerController? victim = @event.Userid; + + if (victim == null || !IsHuman(victim)) + { + return HookResult.Continue; + } + + int dealt = PlayerRoster.Dealt( + @event.DmgHealth, + @event.Health, + _health.TryGetValue(victim.Slot, out int before) ? before : null + ); + _health[victim.Slot] = @event.Health; + + CCSPlayerController? attacker = @event.Attacker; + + if ( + attacker == null + || !IsHuman(attacker) + || !PlayerRoster.CountsAsKill( + attacker.Slot, + victim.Slot, + attacker.TeamNum, + victim.TeamNum, + TeammatesAreEnemies() + ) + ) + { + return HookResult.Continue; + } + + _roster.Hurt(VerifiedSteamIdOf(attacker), VerifiedSteamIdOf(victim), dealt); + } + catch (Exception error) + { + Logger.LogError(error, "unable to count damage"); + } + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) + { + try + { + CCSPlayerController? player = @event.Userid; + + if (player != null) + { + _health.Remove(player.Slot); + } + } + catch (Exception error) + { + Logger.LogError(error, "unable to reset a player's health"); + } + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info) + { + try + { + // Fired when the game starts or restarts, not at the end of a + // round anybody played. + if (@event.Message == "#SFUI_Notice_Game_Commencing") + { + return HookResult.Continue; + } + + Dictionary teams = new(); + + foreach (CCSPlayerController player in Humans()) + { + string? steamId = VerifiedSteamIdOf(player); + + if (steamId != null) + { + teams[steamId] = player.TeamNum; + } + } + + _roster.RoundEnded(@event.Winner, teams, IsWarmup()); + } + catch (Exception error) + { + Logger.LogError(error, "unable to count a round"); + } + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnRoundMvp(EventRoundMvp @event, GameEventInfo info) + { + try + { + CCSPlayerController? player = @event.Userid; + + if (player != null && IsHuman(player)) + { + _roster.Mvp(VerifiedSteamIdOf(player)); + } + } + catch (Exception error) + { + Logger.LogError(error, "unable to count an MVP"); + } + + return HookResult.Continue; + } + // Kept apart from the enforcement that follows it on the tick, which must // run whatever happens to the roster. private void ObserveRoster(List humans) @@ -450,6 +593,23 @@ private static bool TeammatesAreEnemies() } } + // Unreadable is taken as live, as a server without warmup is. + private static bool IsWarmup() + { + try + { + return Utilities + .FindAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault() + ?.GameRules?.WarmupPeriod + ?? false; + } + catch (Exception) + { + return false; + } + } + private HookResult OnChat(CCSPlayerController? player, CommandInfo info) { if (player == null || !IsHuman(player)) diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 47bd7953..cead4151 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -52,6 +52,10 @@ public partial class PlayerManagementPlugin : BasePlugin private readonly HashSet _mutedByUs = new(); private readonly HashSet _kicked = new(); + // Each player's health after the last hit they survived, by slot, so a + // lethal hit is credited only with what it took. Cleared when they spawn. + private readonly Dictionary _health = new(); + private EventDelegates.OnTick? _tickHandler; private EventDelegates.OnClientPutInServer? _putInServerHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; @@ -453,6 +457,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) } IPlayer? attacker = @event.AttackerPlayer; + IPlayer? assister = @event.AssisterPlayer; + bool teammatesAreEnemies = TeammatesAreEnemies(); bool kill = attacker != null @@ -462,10 +468,27 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) victim.PlayerID, attacker.Controller.TeamNum, victim.Controller.TeamNum, - TeammatesAreEnemies() + teammatesAreEnemies ); - _roster.Died(VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null); + bool assist = + assister != null + && IsHuman(assister) + && PlayerRoster.CountsAsAssist( + assister.PlayerID, + victim.PlayerID, + assister.Controller.TeamNum, + victim.Controller.TeamNum, + teammatesAreEnemies, + @event.AssistedFlash + ); + + _roster.Died( + VerifiedSteamIdOf(victim), + kill ? VerifiedSteamIdOf(attacker!) : null, + assist ? VerifiedSteamIdOf(assister!) : null, + @event.Headshot + ); } catch (Exception error) { @@ -475,6 +498,126 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) return HookResult.Continue; } + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerHurt(EventPlayerHurt @event) + { + try + { + IPlayer? victim = @event.UserIdPlayer; + + if (victim == null || !IsHuman(victim)) + { + return HookResult.Continue; + } + + int dealt = PlayerRoster.Dealt( + @event.ActualDmgHealth, + @event.ActualHealth, + _health.TryGetValue(victim.PlayerID, out int before) ? before : null + ); + _health[victim.PlayerID] = @event.ActualHealth; + + IPlayer? attacker = @event.AttackerPlayer; + + if ( + attacker == null + || !IsHuman(attacker) + || !PlayerRoster.CountsAsKill( + attacker.PlayerID, + victim.PlayerID, + attacker.Controller.TeamNum, + victim.Controller.TeamNum, + TeammatesAreEnemies() + ) + ) + { + return HookResult.Continue; + } + + _roster.Hurt(VerifiedSteamIdOf(attacker), VerifiedSteamIdOf(victim), dealt); + } + catch (Exception error) + { + _logger.LogError(error, "unable to count damage"); + } + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerSpawn(EventPlayerSpawn @event) + { + try + { + IPlayer? player = @event.UserIdPlayer; + + if (player != null) + { + _health.Remove(player.PlayerID); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to reset a player's health"); + } + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnRoundEnd(EventRoundEnd @event) + { + try + { + // Fired when the game starts or restarts, not at the end of a + // round anybody played. + if (@event.Message == "#SFUI_Notice_Game_Commencing") + { + return HookResult.Continue; + } + + Dictionary teams = new(); + + foreach (IPlayer player in Humans()) + { + string? steamId = VerifiedSteamIdOf(player); + + if (steamId != null) + { + teams[steamId] = player.Controller.TeamNum; + } + } + + _roster.RoundEnded(@event.Winner, teams, IsWarmup()); + } + catch (Exception error) + { + _logger.LogError(error, "unable to count a round"); + } + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnRoundMvp(EventRoundMvp @event) + { + try + { + IPlayer? player = @event.UserIdPlayer; + + if (player != null && IsHuman(player)) + { + _roster.Mvp(VerifiedSteamIdOf(player)); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to count an MVP"); + } + + return HookResult.Continue; + } + // Kept apart from the enforcement that follows it on the tick, which must // run whatever happens to the roster. private void ObserveRoster(List humans) @@ -502,6 +645,22 @@ private bool TeammatesAreEnemies() } } + // Unreadable is taken as live, as a server without warmup is. + private bool IsWarmup() + { + try + { + return Core.EntitySystem.GetAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault() + ?.GameRules?.WarmupPeriod + ?? false; + } + catch (Exception) + { + return false; + } + } + // Still called while the server hibernates, when OnTick is not, which is // how the roster tells an empty server from one loading a map, and what // lets an empty server reload its map. diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index cf667337..e4507ace 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -890,6 +890,378 @@ public void EachConnectionDepartsOnItsOwn() Assert.NotEqual(report.Departed[0].conn, report.Departed[1].conn); } + private sealed record Counters( + int Kills, + int Deaths, + int Assists, + int Headshots, + int Damage, + int RoundsPlayed, + int RoundsWon, + int Mvps + ); + + private static Counters CountersOf(RosterPlayer player) + { + return new Counters( + player.kills, + player.deaths, + player.assists, + player.headshots, + player.damage, + player.rounds_played, + player.rounds_won, + player.mvps + ); + } + + private static Counters CountersOf(DepartedPlayer player) + { + return new Counters( + player.kills, + player.deaths, + player.assists, + player.headshots, + player.damage, + player.rounds_played, + player.rounds_won, + player.mvps + ); + } + + private static Counters CountersOf(RosterReport report, string steamId) + { + return CountersOf(report.Players!.Single(player => player.steam_id == steamId)); + } + + private static (int Played, int Won) Rounds(RosterReport report, string steamId) + { + Counters counters = CountersOf(report, steamId); + + return (counters.RoundsPlayed, counters.RoundsWon); + } + + private static Dictionary Teams(params (string SteamId, int Team)[] teams) + { + return teams.ToDictionary(team => team.SteamId, team => team.Team); + } + + [Fact] + public void AssistsAndHeadshotsGoToRosterPlayersOnly() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died("2", "1", "3", headshot: true); + roster.Died("2", "1", "bot", headshot: false); + roster.Died("3", "bot", "1", headshot: true); + roster.Died("3", null, null, headshot: true); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(new Counters(2, 0, 1, 1, 0, 0, 0, 0), CountersOf(report, "1")); + Assert.Equal(new Counters(0, 2, 0, 0, 0, 0, 0, 0), CountersOf(report, "2")); + Assert.Equal(new Counters(0, 2, 1, 0, 0, 0, 0, 0), CountersOf(report, "3")); + } + + // The hooks pass no killer for a kill that does not count, so a team kill's + // headshot is not one. + [Fact] + public void AHeadshotCountsOnlyWithACountedKill() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + bool teamKill = PlayerRoster.CountsAsKill( + 1, + 2, + PlayerRoster.TeamT, + PlayerRoster.TeamT, + false + ); + + roster.Died("2", teamKill ? "1" : null, null, headshot: true); + roster.Died("2", "1", null, headshot: true); + + Assert.Equal(1, CountersOf(roster.Report(At(1)), "1").Headshots); + } + + [Theory] + [InlineData(1, 2, 2, 3, false, false, true)] + [InlineData(1, 2, 2, 3, false, true, false)] + [InlineData(1, 2, 2, 2, false, false, false)] + [InlineData(1, 2, 2, 2, true, false, true)] + [InlineData(1, 1, 2, 2, true, false, false)] + public void AnAssistIsDamageToAnEnemyNotAFlash( + int assister, + int victim, + int assisterTeam, + int victimTeam, + bool teammatesAreEnemies, + bool flash, + bool expected + ) + { + Assert.Equal( + expected, + PlayerRoster.CountsAsAssist( + assister, + victim, + assisterTeam, + victimTeam, + teammatesAreEnemies, + flash + ) + ); + } + + [Fact] + public void AFlashAssistEarnsNothing() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + bool flash = PlayerRoster.CountsAsAssist( + 3, + 2, + PlayerRoster.TeamCT, + PlayerRoster.TeamT, + false, + true + ); + bool damage = PlayerRoster.CountsAsAssist( + 3, + 2, + PlayerRoster.TeamCT, + PlayerRoster.TeamT, + false, + false + ); + + roster.Died("2", "1", flash ? "3" : null); + roster.Died("2", "1", damage ? "3" : null); + + Assert.Equal(1, CountersOf(roster.Report(At(1)), "3").Assists); + } + + [Fact] + public void DamageIsCountedForTheAttackerOnly() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Hurt("1", "2", 27); + roster.Hurt("1", "2", 73); + roster.Hurt("1", "1", 50); + roster.Hurt("1", "2", 0); + roster.Hurt("1", "2", -5); + roster.Hurt("bot", "2", 40); + roster.Hurt(null, "2", 40); + roster.Hurt("1", null, 10); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(110, CountersOf(report, "1").Damage); + Assert.Equal(0, CountersOf(report, "2").Damage); + } + + // Damage goes by the kill rule: a teammate's or one's own health is no + // damage, unless teammates are enemies. + [Theory] + [InlineData(1, 2, 2, 3, false, 30)] + [InlineData(1, 2, 2, 2, false, 0)] + [InlineData(1, 1, 2, 2, false, 0)] + [InlineData(1, 2, 2, 2, true, 30)] + public void TeamDamageEarnsNothing( + int attacker, + int victim, + int attackerTeam, + int victimTeam, + bool teammatesAreEnemies, + int expected + ) + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + bool enemy = PlayerRoster.CountsAsKill( + attacker, + victim, + attackerTeam, + victimTeam, + teammatesAreEnemies + ); + + roster.Hurt(enemy ? "1" : null, attacker == victim ? "1" : "2", 30); + + Assert.Equal(expected, CountersOf(roster.Report(At(1)), "1").Damage); + } + + [Theory] + [InlineData(27, 73, null, 27)] + [InlineData(27, 73, 40, 27)] + [InlineData(448, 0, null, 100)] + [InlineData(448, 0, 31, 31)] + [InlineData(20, 0, 31, 20)] + [InlineData(448, 0, 0, 0)] + [InlineData(-3, 50, null, 0)] + public void ALethalHitIsCappedAtTheHealthThatWasLeft( + int damage, + int healthAfter, + int? healthBefore, + int expected + ) + { + Assert.Equal(expected, PlayerRoster.Dealt(damage, healthAfter, healthBefore)); + } + + // Player 1 switches sides between the rounds and wins both: a round goes to + // the side a player ends it on. + [Fact] + public void ARoundIsPlayedByEveryoneOnASideAndWonByTheWinners() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3"), Human("4")], Start); + + roster.RoundEnded( + PlayerRoster.TeamT, + Teams( + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamCT), + ("3", 1), + ("4", 0), + ("bot", PlayerRoster.TeamT) + ), + warmup: false + ); + roster.RoundEnded( + PlayerRoster.TeamCT, + Teams( + ("1", PlayerRoster.TeamCT), + ("2", PlayerRoster.TeamCT), + ("3", PlayerRoster.TeamT) + ), + warmup: false + ); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal((2, 2), Rounds(report, "1")); + Assert.Equal((2, 1), Rounds(report, "2")); + Assert.Equal((1, 0), Rounds(report, "3")); + Assert.Equal((0, 0), Rounds(report, "4")); + } + + // A draw has no winners, and is still a round everybody on a side played. + [Fact] + public void ADrawIsPlayedAndWonByNobody() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.RoundEnded( + 1, + Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)), + warmup: false + ); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal((1, 0), Rounds(report, "1")); + Assert.Equal((1, 0), Rounds(report, "2")); + } + + [Fact] + public void WarmupRoundsAreNotCounted() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true); + + Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(1)), "1")); + } + + [Fact] + public void AnMvpGoesToARosterPlayerOnly() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Mvp("1"); + roster.Mvp("1"); + roster.Mvp("bot"); + roster.Mvp(null); + + Assert.Equal(2, CountersOf(roster.Report(At(1)), "1").Mvps); + } + + private static void CountEverything(PlayerRoster roster, string steamId, string enemy) + { + roster.Died(enemy, steamId, null, headshot: true); + roster.Died(steamId, enemy, null); + roster.Died(enemy, null, steamId); + roster.Hurt(steamId, enemy, 64); + roster.RoundEnded( + PlayerRoster.TeamT, + Teams((steamId, PlayerRoster.TeamT), (enemy, PlayerRoster.TeamCT)), + warmup: false + ); + roster.Mvp(steamId); + } + + [Fact] + public async Task EveryCounterStartsOverWithANewConnectionAndDepartsWithItsFinalValue() + { + var (loop, roster, _, panel) = Loop(); + loop.Observe([Human("1"), Human("2")], Start); + CountEverything(roster, "1", "2"); + CountEverything(roster, "1", "2"); + await loop.Tick(Start); + + loop.Observe([Human("2")], At(1)); + loop.Observe([Human("1"), Human("2")], At(2)); + CountEverything(roster, "1", "2"); + await loop.Tick(At(1) + PlayerRoster.Debounce); + + JsonElement departed = Assert.Single( + panel.Bodies[1].GetProperty("departed").EnumerateArray() + ); + JsonElement current = panel + .Bodies[1] + .GetProperty("players") + .EnumerateArray() + .Single(player => player.GetProperty("steam_id").GetString() == "1"); + + Assert.Equal(panel.Conns(0)["1"], departed.GetProperty("conn").GetString()); + Assert.Equal( + new Counters(2, 2, 2, 2, 128, 2, 2, 2), + JsonSerializer.Deserialize(departed.GetRawText(), Snake) + ); + Assert.Equal( + new Counters(1, 1, 1, 1, 64, 1, 1, 1), + JsonSerializer.Deserialize(current.GetRawText(), Snake) + ); + } + + private static readonly JsonSerializerOptions Snake = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + // A departure is a snapshot: counting more for a player who has left + // changes nothing about the connection that ended. + [Fact] + public void ADepartureKeepsTheCountersItLeftWith() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + CountEverything(roster, "1", "2"); + roster.Observe([Human("2")], At(1)); + CountEverything(roster, "1", "2"); + + DepartedPlayer departed = Assert.Single(roster.Report(At(2)).Departed); + + Assert.Equal(new Counters(1, 1, 1, 1, 64, 1, 1, 1), CountersOf(departed)); + } + [Fact] public void DeparturesGoOutOldestFirstAndAtMostSixtyFourAtATime() { @@ -992,6 +1364,12 @@ public void ARosterSerializesAsTheApiExpects() ip = null, kills = 12, deaths = 9, + assists = 5, + headshots = 7, + damage = 1480, + rounds_played = 16, + rounds_won = 10, + mvps = 3, }, ], departed = @@ -1002,6 +1380,12 @@ public void ARosterSerializesAsTheApiExpects() conn = "c2", kills = 3, deaths = 4, + assists = 1, + headshots = 2, + damage = 410, + rounds_played = 6, + rounds_won = 2, + mvps = 1, }, ], }; @@ -1013,11 +1397,11 @@ public void ARosterSerializesAsTheApiExpects() Assert.Contains("\"departed\":[]", JsonSerializer.Serialize(unknown, SanctionsClient.Json)); Assert.Contains("\"players\":[]", JsonSerializer.Serialize(empty, SanctionsClient.Json)); Assert.Contains( - "\"players\":[{\"steam_id\":\"76561198000000001\",\"conn\":\"c1\",\"name\":\"nyx\",\"ip\":null,\"kills\":12,\"deaths\":9}]", + "\"players\":[{\"steam_id\":\"76561198000000001\",\"conn\":\"c1\",\"name\":\"nyx\",\"ip\":null,\"kills\":12,\"deaths\":9,\"assists\":5,\"headshots\":7,\"damage\":1480,\"rounds_played\":16,\"rounds_won\":10,\"mvps\":3}]", JsonSerializer.Serialize(known, SanctionsClient.Json) ); Assert.Contains( - "\"departed\":[{\"steam_id\":\"76561198000000002\",\"conn\":\"c2\",\"kills\":3,\"deaths\":4}]", + "\"departed\":[{\"steam_id\":\"76561198000000002\",\"conn\":\"c2\",\"kills\":3,\"deaths\":4,\"assists\":1,\"headshots\":2,\"damage\":410,\"rounds_played\":6,\"rounds_won\":2,\"mvps\":1}]", JsonSerializer.Serialize(known, SanctionsClient.Json) ); } diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs index a1cbc8c0..244f6e72 100644 --- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs @@ -33,8 +33,9 @@ public class PlayerSanctionsRequest public string? map { get; set; } } -// Kills and deaths are running totals for the connection named by conn, which -// is new each time a player joins, so the panel never has to guess at one. +// Kills, deaths and the counters after them are running totals for the +// connection named by conn, which is new each time a player joins, so the +// panel never has to guess at one. public class RosterPlayer { public string steam_id { get; set; } = ""; @@ -43,6 +44,14 @@ public class RosterPlayer public string? ip { get; set; } public int kills { get; set; } public int deaths { get; set; } + + // Ignored by panels that predate the community counters. + public int assists { get; set; } + public int headshots { get; set; } + public int damage { get; set; } + public int rounds_played { get; set; } + public int rounds_won { get; set; } + public int mvps { get; set; } } public class DepartedPlayer @@ -51,6 +60,14 @@ public class DepartedPlayer public string conn { get; set; } = ""; public int kills { get; set; } public int deaths { get; set; } + + // Ignored by panels that predate the community counters. + public int assists { get; set; } + public int headshots { get; set; } + public int damage { get; set; } + public int rounds_played { get; set; } + public int rounds_won { get; set; } + public int mvps { get; set; } } public class PlayerSanctionsResponse diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 9404a299..85dceea2 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -35,6 +35,13 @@ public class PlayerRoster public const int MaxNameLength = 64; + public const int TeamT = 2; + + public const int TeamCT = 3; + + // A player who has not been hit since they spawned. + public const int FullHealth = 100; + private enum eRosterState { Unknown, @@ -49,6 +56,12 @@ private sealed class Entry public string? Ip; public int Kills; public int Deaths; + public int Assists; + public int Headshots; + public int Damage; + public int RoundsPlayed; + public int RoundsWon; + public int Mvps; } private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); @@ -189,8 +202,10 @@ public void Hibernating(DateTimeOffset now) } // Only players on the roster are counted, which keeps out bots and any - // claimed id Steam has not verified. - public void Died(string? victim, string? killer) + // claimed id Steam has not verified. The hooks pass a killer or assister + // only when the kill or assist counts, so a headshot only ever goes with a + // counted kill. + public void Died(string? victim, string? killer, string? assister = null, bool headshot = false) { lock (_lock) { @@ -202,6 +217,85 @@ public void Died(string? victim, string? killer) if (killer != null && _current.TryGetValue(killer, out Entry? scorer)) { scorer.Kills++; + + if (headshot) + { + scorer.Headshots++; + } + } + + if ( + assister != null + && assister != killer + && _current.TryGetValue(assister, out Entry? helper) + ) + { + helper.Assists++; + } + } + } + + // The hooks pass an attacker only for damage to an enemy, by the same rule + // as a kill, and the damage already capped by Dealt. + public void Hurt(string? attacker, string? victim, int damage) + { + if (attacker == null || attacker == victim || damage <= 0) + { + return; + } + + lock (_lock) + { + if (_current.TryGetValue(attacker, out Entry? entry)) + { + entry.Damage += damage; + } + } + } + + // A round is played by whoever is on a side when it ends, so a player who + // switches sides mid-round plays it for the side they ended it on, and a + // spectator plays none. Warmup rounds are not rounds. + public void RoundEnded( + int winnerTeam, + IReadOnlyDictionary teamsBySteamId, + bool warmup + ) + { + if (warmup) + { + return; + } + + lock (_lock) + { + foreach ((string steamId, int team) in teamsBySteamId) + { + if ( + (team != TeamT && team != TeamCT) + || !_current.TryGetValue(steamId, out Entry? entry) + ) + { + continue; + } + + entry.RoundsPlayed++; + + if (team == winnerTeam) + { + entry.RoundsWon++; + } + } + } + } + + public void Mvp(string? steamId) + { + lock (_lock) + { + if (steamId != null && _current.TryGetValue(steamId, out Entry? entry)) + { + entry.Mvps++; } } } @@ -321,6 +415,37 @@ bool teammatesAreEnemies return attackerSlot != victimSlot && (teammatesAreEnemies || attackerTeam != victimTeam); } + // A flash assist only blinded the victim, which did nothing to their health. + public static bool CountsAsAssist( + int assisterSlot, + int victimSlot, + int assisterTeam, + int victimTeam, + bool teammatesAreEnemies, + bool flash + ) + { + return !flash + && CountsAsKill( + assisterSlot, + victimSlot, + assisterTeam, + victimTeam, + teammatesAreEnemies + ); + } + + // A hit reports its full damage even when it kills, so a lethal one is + // capped at the health the victim had before it: what the last hit they + // survived left them, or full health when they have not been hit since + // spawning. + public static int Dealt(int damage, int healthAfter, int? healthBefore) + { + int before = healthAfter > 0 ? healthAfter + damage : healthBefore ?? FullHealth; + + return Math.Clamp(damage, 0, Math.Max(before, 0)); + } + // The panel checks the name itself; this only keeps an empty one from // reading as a map. private static string? MapName(string? raw) @@ -341,6 +466,12 @@ private List Players() ip = entry.Value.Ip, kills = entry.Value.Kills, deaths = entry.Value.Deaths, + assists = entry.Value.Assists, + headshots = entry.Value.Headshots, + damage = entry.Value.Damage, + rounds_played = entry.Value.RoundsPlayed, + rounds_won = entry.Value.RoundsWon, + mvps = entry.Value.Mvps, }) .ToList(); } @@ -355,6 +486,12 @@ private List Departures() conn = departure.Player.conn, kills = departure.Player.kills, deaths = departure.Player.deaths, + assists = departure.Player.assists, + headshots = departure.Player.headshots, + damage = departure.Player.damage, + rounds_played = departure.Player.rounds_played, + rounds_won = departure.Player.rounds_won, + mvps = departure.Player.mvps, }) .ToList(); } @@ -446,6 +583,12 @@ private void Reconcile(DateTimeOffset now) conn = entry.Conn, kills = entry.Kills, deaths = entry.Deaths, + assists = entry.Assists, + headshots = entry.Headshots, + damage = entry.Damage, + rounds_played = entry.RoundsPlayed, + rounds_won = entry.RoundsWon, + mvps = entry.Mvps, }, now ) From 5053b937b94f4cda884545721ff0bf385403636d Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 30 Oct 2026 11:16:38 +0100 Subject: [PATCH 04/13] bug: nothing a player does in warmup counts toward the community stats Kills, deaths, assists, headshots, damage and MVPs are now dropped while the game rules say warmup is on, the same way warmup rounds already were. The flag is read at each event, so it holds however long warmup runs and however it ends; a server without warmup counts everything. --- .../src/PlayerManagementPlugin.cs | 10 +- .../src/PlayerManagementPlugin.cs | 10 +- .../test/PlayerRosterTests.cs | 106 +++++++++++------- .../FiveStack.Utilities/PlayerRoster.cs | 28 ++++- 4 files changed, 104 insertions(+), 50 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 2ff7675d..ec3d8f56 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -434,6 +434,7 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) _roster.Died( VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null, + IsWarmup(), assist ? VerifiedSteamIdOf(assister!) : null, @event.Headshot ); @@ -482,7 +483,12 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) return HookResult.Continue; } - _roster.Hurt(VerifiedSteamIdOf(attacker), VerifiedSteamIdOf(victim), dealt); + _roster.Hurt( + VerifiedSteamIdOf(attacker), + VerifiedSteamIdOf(victim), + dealt, + IsWarmup() + ); } catch (Exception error) { @@ -555,7 +561,7 @@ public HookResult OnRoundMvp(EventRoundMvp @event, GameEventInfo info) if (player != null && IsHuman(player)) { - _roster.Mvp(VerifiedSteamIdOf(player)); + _roster.Mvp(VerifiedSteamIdOf(player), IsWarmup()); } } catch (Exception error) diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index cead4151..69436292 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -486,6 +486,7 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) _roster.Died( VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null, + IsWarmup(), assist ? VerifiedSteamIdOf(assister!) : null, @event.Headshot ); @@ -534,7 +535,12 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) return HookResult.Continue; } - _roster.Hurt(VerifiedSteamIdOf(attacker), VerifiedSteamIdOf(victim), dealt); + _roster.Hurt( + VerifiedSteamIdOf(attacker), + VerifiedSteamIdOf(victim), + dealt, + IsWarmup() + ); } catch (Exception error) { @@ -607,7 +613,7 @@ public HookResult OnRoundMvp(EventRoundMvp @event) if (player != null && IsHuman(player)) { - _roster.Mvp(VerifiedSteamIdOf(player)); + _roster.Mvp(VerifiedSteamIdOf(player), IsWarmup()); } } catch (Exception error) diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index e4507ace..d5bc14bc 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -289,7 +289,7 @@ public async Task ASilentServerIsEmptyOnceTheTicksStop() { var (loop, roster, _, panel) = Loop(); loop.Observe([Human("1")], Start); - roster.Died(null, "1"); + roster.Died(null, "1", warmup: false); await loop.Tick(Start); await loop.Tick(Start + PlayerRoster.Silence); @@ -534,10 +534,10 @@ public async Task CountersBelongToTheConnectionAndStartOverOnRejoin() { var (loop, roster, _, panel) = Loop(); loop.Observe([Human("1"), Human("2")], Start); - roster.Died("2", "1"); - roster.Died("2", "1"); - roster.Died("1", null); - roster.Died(null, "bot"); + roster.Died("2", "1", warmup: false); + roster.Died("2", "1", warmup: false); + roster.Died("1", null, warmup: false); + roster.Died(null, "bot", warmup: false); await loop.Tick(Start); Assert.Equal( @@ -550,7 +550,7 @@ public async Task CountersBelongToTheConnectionAndStartOverOnRejoin() loop.Observe([Human("2")], At(1)); loop.Observe([Human("1"), Human("2")], At(2)); - roster.Died("2", "1"); + roster.Died("2", "1", warmup: false); await loop.Tick(At(1) + PlayerRoster.Debounce); Assert.Equal(1, panel.Players(1)![0].Kills); @@ -585,7 +585,7 @@ public async Task DeparturesAreKeptUntilASyncSucceeds() loop.Observe([Human("1")], Start); await loop.Tick(Start); - roster.Died(null, "1"); + roster.Died(null, "1", warmup: false); loop.Observe([], At(1)); panel.Answer = () => Task.FromResult(Panel.Down()); @@ -679,8 +679,8 @@ public async Task AMapsLastKillsAreReportedOnThatMap() loop.Observe([Human("1"), Human("2")], Start); await loop.Tick(Start); - roster.Died("2", "1"); - roster.Died("2", "1"); + roster.Died("2", "1", warmup: false); + roster.Died("2", "1", warmup: false); roster.MapEnded(At(5)); roster.MapStarted(At(6), "de_inferno"); await loop.Tick(At(6)); @@ -700,7 +700,7 @@ public async Task AMapsLastKillsAreReportedOnThatMap() Assert.Equal("de_inferno", panel.Map(2)); loop.Observe([Human("1"), Human("2")], At(6) + SanctionSyncLoop.Interval * 2); - roster.Died("1", "2"); + roster.Died("1", "2", warmup: false); await loop.Tick(At(6) + SanctionSyncLoop.Interval * 2); Assert.Equal("de_inferno", panel.Map(3)); @@ -721,7 +721,7 @@ public async Task AMapEndReportThePanelMissedIsSentAgain() loop.Observe([Human("1")], Start); await loop.Tick(Start); - roster.Died(null, "1"); + roster.Died(null, "1", warmup: false); roster.MapEnded(At(5)); roster.MapStarted(At(6), "de_inferno"); panel.Answer = () => Task.FromResult(Panel.Down()); @@ -873,12 +873,12 @@ public void EachConnectionDepartsOnItsOwn() { PlayerRoster roster = new(); roster.Observe([Human("1")], Start); - roster.Died(null, "1"); + roster.Died(null, "1", warmup: false); roster.Observe([], At(1)); roster.Observe([Human("1")], At(2)); - roster.Died(null, "1"); - roster.Died(null, "1"); - roster.Died("1", null); + roster.Died(null, "1", warmup: false); + roster.Died(null, "1", warmup: false); + roster.Died("1", null, warmup: false); roster.Observe([], At(3)); RosterReport report = roster.Report(At(4)); @@ -952,10 +952,10 @@ public void AssistsAndHeadshotsGoToRosterPlayersOnly() PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2"), Human("3")], Start); - roster.Died("2", "1", "3", headshot: true); - roster.Died("2", "1", "bot", headshot: false); - roster.Died("3", "bot", "1", headshot: true); - roster.Died("3", null, null, headshot: true); + roster.Died("2", "1", warmup: false, assister: "3", headshot: true); + roster.Died("2", "1", warmup: false, assister: "bot", headshot: false); + roster.Died("3", "bot", warmup: false, assister: "1", headshot: true); + roster.Died("3", null, warmup: false, assister: null, headshot: true); RosterReport report = roster.Report(At(1)); @@ -979,8 +979,8 @@ public void AHeadshotCountsOnlyWithACountedKill() false ); - roster.Died("2", teamKill ? "1" : null, null, headshot: true); - roster.Died("2", "1", null, headshot: true); + roster.Died("2", teamKill ? "1" : null, warmup: false, headshot: true); + roster.Died("2", "1", warmup: false, assister: null, headshot: true); Assert.Equal(1, CountersOf(roster.Report(At(1)), "1").Headshots); } @@ -1036,8 +1036,8 @@ public void AFlashAssistEarnsNothing() false ); - roster.Died("2", "1", flash ? "3" : null); - roster.Died("2", "1", damage ? "3" : null); + roster.Died("2", "1", warmup: false, assister: flash ? "3" : null); + roster.Died("2", "1", warmup: false, assister: damage ? "3" : null); Assert.Equal(1, CountersOf(roster.Report(At(1)), "3").Assists); } @@ -1048,14 +1048,14 @@ public void DamageIsCountedForTheAttackerOnly() PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2")], Start); - roster.Hurt("1", "2", 27); - roster.Hurt("1", "2", 73); - roster.Hurt("1", "1", 50); - roster.Hurt("1", "2", 0); - roster.Hurt("1", "2", -5); - roster.Hurt("bot", "2", 40); - roster.Hurt(null, "2", 40); - roster.Hurt("1", null, 10); + roster.Hurt("1", "2", 27, warmup: false); + roster.Hurt("1", "2", 73, warmup: false); + roster.Hurt("1", "1", 50, warmup: false); + roster.Hurt("1", "2", 0, warmup: false); + roster.Hurt("1", "2", -5, warmup: false); + roster.Hurt("bot", "2", 40, warmup: false); + roster.Hurt(null, "2", 40, warmup: false); + roster.Hurt("1", null, 10, warmup: false); RosterReport report = roster.Report(At(1)); @@ -1089,7 +1089,7 @@ int expected teammatesAreEnemies ); - roster.Hurt(enemy ? "1" : null, attacker == victim ? "1" : "2", 30); + roster.Hurt(enemy ? "1" : null, attacker == victim ? "1" : "2", 30, warmup: false); Assert.Equal(expected, CountersOf(roster.Report(At(1)), "1").Damage); } @@ -1179,32 +1179,56 @@ public void WarmupRoundsAreNotCounted() Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(1)), "1")); } + // Warmup ends without a round of its own: what players do in it is + // dropped, and what they do after it counts. + [Fact] + public void NothingDoneInWarmupIsCounted() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died("2", "1", warmup: true, assister: "3", headshot: true); + roster.Hurt("1", "2", 100, warmup: true); + roster.Mvp("1", warmup: true); + roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true); + + roster.Died("2", "1", warmup: false, assister: "3", headshot: true); + roster.Hurt("1", "2", 100, warmup: false); + roster.Mvp("1", warmup: false); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(new Counters(1, 0, 0, 1, 100, 0, 0, 1), CountersOf(report, "1")); + Assert.Equal(new Counters(0, 1, 0, 0, 0, 0, 0, 0), CountersOf(report, "2")); + Assert.Equal(new Counters(0, 0, 1, 0, 0, 0, 0, 0), CountersOf(report, "3")); + } + [Fact] public void AnMvpGoesToARosterPlayerOnly() { PlayerRoster roster = new(); roster.Observe([Human("1")], Start); - roster.Mvp("1"); - roster.Mvp("1"); - roster.Mvp("bot"); - roster.Mvp(null); + roster.Mvp("1", warmup: false); + roster.Mvp("1", warmup: false); + roster.Mvp("bot", warmup: false); + roster.Mvp(null, warmup: false); Assert.Equal(2, CountersOf(roster.Report(At(1)), "1").Mvps); } private static void CountEverything(PlayerRoster roster, string steamId, string enemy) { - roster.Died(enemy, steamId, null, headshot: true); - roster.Died(steamId, enemy, null); - roster.Died(enemy, null, steamId); - roster.Hurt(steamId, enemy, 64); + roster.Died(enemy, steamId, warmup: false, assister: null, headshot: true); + roster.Died(steamId, enemy, warmup: false); + roster.Died(enemy, null, warmup: false, assister: steamId); + roster.Hurt(steamId, enemy, 64, warmup: false); roster.RoundEnded( PlayerRoster.TeamT, Teams((steamId, PlayerRoster.TeamT), (enemy, PlayerRoster.TeamCT)), warmup: false ); - roster.Mvp(steamId); + roster.Mvp(steamId, warmup: false); } [Fact] diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 85dceea2..67363875 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -204,9 +204,22 @@ public void Hibernating(DateTimeOffset now) // Only players on the roster are counted, which keeps out bots and any // claimed id Steam has not verified. The hooks pass a killer or assister // only when the kill or assist counts, so a headshot only ever goes with a - // counted kill. - public void Died(string? victim, string? killer, string? assister = null, bool headshot = false) + // counted kill. Nothing done in warmup counts: its kills, deaths, damage + // and MVPs no more than its rounds. The game rules' warmup flag says so at + // each event, however long the warmup runs or however it ends. + public void Died( + string? victim, + string? killer, + bool warmup, + string? assister = null, + bool headshot = false + ) { + if (warmup) + { + return; + } + lock (_lock) { if (victim != null && _current.TryGetValue(victim, out Entry? dead)) @@ -237,9 +250,9 @@ public void Died(string? victim, string? killer, string? assister = null, bool h // The hooks pass an attacker only for damage to an enemy, by the same rule // as a kill, and the damage already capped by Dealt. - public void Hurt(string? attacker, string? victim, int damage) + public void Hurt(string? attacker, string? victim, int damage, bool warmup) { - if (attacker == null || attacker == victim || damage <= 0) + if (warmup || attacker == null || attacker == victim || damage <= 0) { return; } @@ -289,8 +302,13 @@ bool warmup } } - public void Mvp(string? steamId) + public void Mvp(string? steamId, bool warmup) { + if (warmup) + { + return; + } + lock (_lock) { if (steamId != null && _current.TryGetValue(steamId, out Entry? entry)) From f58b7015c5a3fb699f479dbe2cf9afdcd848f954 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 14:10:33 +0100 Subject: [PATCH 05/13] test: a sync in flight at a map change does not stand in for the map-end report, and a kill during the hold stays out of it --- .../test/PlayerRosterTests.cs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index cf667337..404c96d6 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -744,6 +744,106 @@ [new Reported("1", "player 1", "203.0.113.24", 1, 0)], 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] From 6507afdaa437aaa3ae7855ffe0d72b7e03df7ed0 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 14:13:53 +0100 Subject: [PATCH 06/13] bug: a lethal hit after a deathmatch heal counts the health the player had, read off the pawn every second --- .../src/PlayerManagement.csproj | 1 + .../src/PlayerManagementPlugin.cs | 36 +++++++---- .../src/PlayerManagement.csproj | 1 + .../src/PlayerManagementPlugin.cs | 36 +++++++---- .../test/FiveStack.Tests.csproj | 1 + .../test/HealthBookTests.cs | 60 +++++++++++++++++++ .../dotnet/FiveStack.Utilities/HealthBook.cs | 37 ++++++++++++ 7 files changed, 152 insertions(+), 20 deletions(-) create mode 100644 apps/player-management-sw/test/HealthBookTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/HealthBook.cs diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index 14d73b9b..308d379e 100644 --- a/apps/player-management-css/src/PlayerManagement.csproj +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -20,6 +20,7 @@ + diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index ec3d8f56..8eef6b4b 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -45,9 +45,7 @@ public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig _mutedByUs = new(); private readonly HashSet _kicked = new(); - // Each player's health after the last hit they survived, by slot, so a - // lethal hit is credited only with what it took. Cleared when they spawn. - private readonly Dictionary _health = new(); + private readonly HealthBook _health = new(); private long _lastEnforceMs; @@ -264,6 +262,7 @@ private void OnTick() List humans = Humans(); ObserveRoster(humans); + ReadHealth(humans); Enforce(humans); } catch (Exception error) @@ -459,12 +458,7 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) return HookResult.Continue; } - int dealt = PlayerRoster.Dealt( - @event.DmgHealth, - @event.Health, - _health.TryGetValue(victim.Slot, out int before) ? before : null - ); - _health[victim.Slot] = @event.Health; + int dealt = _health.Hit(victim.Slot, @event.DmgHealth, @event.Health); CCSPlayerController? attacker = @event.Attacker; @@ -507,7 +501,7 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) if (player != null) { - _health.Remove(player.Slot); + _health.Spawned(player.Slot); } } catch (Exception error) @@ -586,6 +580,28 @@ private void ObserveRoster(List humans) } } + // Also kept apart from the enforcement. A pawn that cannot be read keeps + // the health its last hit left. + private void ReadHealth(List humans) + { + foreach (CCSPlayerController player in humans) + { + try + { + CCSPlayerPawn? pawn = player.PlayerPawn.Value; + + if (pawn != null && pawn.IsValid) + { + _health.Seen(player.Slot, pawn.Health); + } + } + catch (Exception error) + { + Logger.LogDebug(error, "unable to read a player's health"); + } + } + } + // Unreadable is taken as teams as usual, where a team kill earns nothing. private static bool TeammatesAreEnemies() { diff --git a/apps/player-management-sw/src/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj index ce23c9ff..8263c99c 100644 --- a/apps/player-management-sw/src/PlayerManagement.csproj +++ b/apps/player-management-sw/src/PlayerManagement.csproj @@ -27,6 +27,7 @@ + diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 69436292..adc86d2d 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -52,9 +52,7 @@ public partial class PlayerManagementPlugin : BasePlugin private readonly HashSet _mutedByUs = new(); private readonly HashSet _kicked = new(); - // Each player's health after the last hit they survived, by slot, so a - // lethal hit is credited only with what it took. Cleared when they spawn. - private readonly Dictionary _health = new(); + private readonly HealthBook _health = new(); private EventDelegates.OnTick? _tickHandler; private EventDelegates.OnClientPutInServer? _putInServerHandler; @@ -329,6 +327,7 @@ private void OnTick() List humans = Humans(); ObserveRoster(humans); + ReadHealth(humans); Enforce(humans); } catch (Exception error) @@ -511,12 +510,7 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) return HookResult.Continue; } - int dealt = PlayerRoster.Dealt( - @event.ActualDmgHealth, - @event.ActualHealth, - _health.TryGetValue(victim.PlayerID, out int before) ? before : null - ); - _health[victim.PlayerID] = @event.ActualHealth; + int dealt = _health.Hit(victim.PlayerID, @event.ActualDmgHealth, @event.ActualHealth); IPlayer? attacker = @event.AttackerPlayer; @@ -559,7 +553,7 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) if (player != null) { - _health.Remove(player.PlayerID); + _health.Spawned(player.PlayerID); } } catch (Exception error) @@ -638,6 +632,28 @@ private void ObserveRoster(List humans) } } + // Also kept apart from the enforcement. A pawn that cannot be read keeps + // the health its last hit left. + private void ReadHealth(List humans) + { + foreach (IPlayer player in humans) + { + try + { + CCSPlayerPawn? pawn = player.PlayerPawn; + + if (pawn != null && pawn.IsValid) + { + _health.Seen(player.PlayerID, pawn.Health); + } + } + catch (Exception error) + { + _logger.LogDebug(error, "unable to read a player's health"); + } + } + } + // Unreadable is taken as teams as usual, where a team kill earns nothing. private bool TeammatesAreEnemies() { diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj index 0849a281..456b1ddc 100644 --- a/apps/player-management-sw/test/FiveStack.Tests.csproj +++ b/apps/player-management-sw/test/FiveStack.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/apps/player-management-sw/test/HealthBookTests.cs b/apps/player-management-sw/test/HealthBookTests.cs new file mode 100644 index 00000000..4d67c6ff --- /dev/null +++ b/apps/player-management-sw/test/HealthBookTests.cs @@ -0,0 +1,60 @@ +using FiveStack.Utilities; +using Xunit; + +public class HealthBookTests +{ + [Fact] + public void ALethalHitIsCappedAtWhatTheLastHitLeft() + { + HealthBook health = new(); + + Assert.Equal(70, health.Hit(1, 70, 30)); + Assert.Equal(30, health.Hit(1, 448, 0)); + } + + // Deathmatch heals a player on a kill and fires nothing for it. + [Fact] + public void AHealWithoutAnEventIsReadOffThePawn() + { + HealthBook health = new(); + health.Hit(1, 70, 30); + + health.Seen(1, 100); + + Assert.Equal(100, health.Hit(1, 448, 0)); + } + + [Fact] + public void ADeadPawnKeepsWhatItsLastHitLeft() + { + HealthBook health = new(); + health.Hit(1, 60, 40); + + health.Seen(1, 0); + + Assert.Equal(40, health.Hit(1, 448, 0)); + } + + [Fact] + public void ASpawnStartsAtFullHealth() + { + HealthBook health = new(); + health.Hit(1, 60, 40); + + health.Spawned(1); + + Assert.Equal(PlayerRoster.FullHealth, health.Hit(1, 448, 0)); + } + + [Fact] + public void EachSlotKeepsItsOwnHealth() + { + HealthBook health = new(); + health.Hit(1, 60, 40); + health.Seen(2, 90); + + Assert.Equal(40, health.Hit(1, 448, 0)); + Assert.Equal(90, health.Hit(2, 448, 0)); + Assert.Equal(PlayerRoster.FullHealth, health.Hit(3, 448, 0)); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/HealthBook.cs b/shared/dotnet/FiveStack.Utilities/HealthBook.cs new file mode 100644 index 00000000..394f6aa6 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HealthBook.cs @@ -0,0 +1,37 @@ +namespace FiveStack.Utilities; + +// Each player's health by slot, so a lethal hit is credited only with what it +// took. A hit says what it left and a spawn clears it, but deathmatch heals a +// player on a kill without an event, so the tick reads it off the pawn too. +// Only touched on the game thread. +public sealed class HealthBook +{ + private readonly Dictionary _health = new(); + + // Returns the damage the hit dealt, capped by PlayerRoster.Dealt. + public int Hit(int slot, int damage, int healthAfter) + { + int dealt = PlayerRoster.Dealt( + damage, + healthAfter, + _health.TryGetValue(slot, out int before) ? before : null + ); + _health[slot] = healthAfter; + + return dealt; + } + + public void Spawned(int slot) + { + _health.Remove(slot); + } + + // A dead pawn has no health to read; its next life starts at its spawn. + public void Seen(int slot, int pawnHealth) + { + if (pawnHealth > 0) + { + _health[slot] = pawnHealth; + } + } +} From f29e3177562e29ed528eddced0ce8f3a0abbbb8b Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 14:19:27 +0100 Subject: [PATCH 07/13] bug: ending warmup hands out no round or MVP, since a round only counts once its freeze time ends outside warmup --- .../src/PlayerManagementPlugin.cs | 18 ++++++ .../src/PlayerManagementPlugin.cs | 18 ++++++ .../test/PlayerRosterTests.cs | 59 +++++++++++++++++++ .../FiveStack.Utilities/PlayerRoster.cs | 28 ++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 8eef6b4b..a841f80b 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -160,6 +160,9 @@ public override void Load(bool hotReload) { Logger.LogDebug(error, "unable to read the map on load"); } + + // Nor has it seen the round under way go live. + _roster.FreezeTimeEnded(IsWarmup()); } _loop.Start(); @@ -512,6 +515,21 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) return HookResult.Continue; } + [GameEventHandler] + public HookResult OnRoundFreezeEnd(EventRoundFreezeEnd @event, GameEventInfo info) + { + try + { + _roster.FreezeTimeEnded(IsWarmup()); + } + catch (Exception error) + { + Logger.LogError(error, "unable to start counting a round"); + } + + return HookResult.Continue; + } + [GameEventHandler] public HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info) { diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index adc86d2d..e84d5438 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -188,6 +188,9 @@ public override void Load(bool hotReload) { _logger.LogDebug(error, "unable to read the map on load"); } + + // Nor has it seen the round under way go live. + _roster.FreezeTimeEnded(IsWarmup()); } _loop.Start(); @@ -564,6 +567,21 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) return HookResult.Continue; } + [GameEventHandler(HookMode.Post)] + public HookResult OnRoundFreezeEnd(EventRoundFreezeEnd @event) + { + try + { + _roster.FreezeTimeEnded(IsWarmup()); + } + catch (Exception error) + { + _logger.LogError(error, "unable to start counting a round"); + } + + return HookResult.Continue; + } + [GameEventHandler(HookMode.Post)] public HookResult OnRoundEnd(EventRoundEnd @event) { diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index f91d4f96..00dfc35e 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1219,6 +1219,7 @@ public void ARoundIsPlayedByEveryoneOnASideAndWonByTheWinners() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2"), Human("3"), Human("4")], Start); + roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( PlayerRoster.TeamT, @@ -1231,6 +1232,7 @@ public void ARoundIsPlayedByEveryoneOnASideAndWonByTheWinners() ), warmup: false ); + roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( PlayerRoster.TeamCT, Teams( @@ -1255,6 +1257,7 @@ public void ADrawIsPlayedAndWonByNobody() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2")], Start); + roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( 1, @@ -1273,6 +1276,7 @@ public void WarmupRoundsAreNotCounted() { PlayerRoster roster = new(); roster.Observe([Human("1")], Start); + roster.FreezeTimeEnded(warmup: true); roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true); @@ -1286,12 +1290,14 @@ public void NothingDoneInWarmupIsCounted() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2"), Human("3")], Start); + roster.FreezeTimeEnded(warmup: true); roster.Died("2", "1", warmup: true, assister: "3", headshot: true); roster.Hurt("1", "2", 100, warmup: true); roster.Mvp("1", warmup: true); roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true); + roster.FreezeTimeEnded(warmup: false); roster.Died("2", "1", warmup: false, assister: "3", headshot: true); roster.Hurt("1", "2", 100, warmup: false); roster.Mvp("1", warmup: false); @@ -1303,11 +1309,63 @@ public void NothingDoneInWarmupIsCounted() Assert.Equal(new Counters(0, 0, 1, 0, 0, 0, 0, 0), CountersOf(report, "3")); } + // Ending warmup ends its round as a win for whoever is alive, and the warmup + // flag can already be off by then. Only the round after it counts. + [Fact] + public void EndingWarmupHandsOutNoRound() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + roster.FreezeTimeEnded(warmup: true); + + roster.RoundEnded( + PlayerRoster.TeamT, + Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)), + warmup: false + ); + roster.Mvp("1", warmup: false); + + Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(1)), "1")); + + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded( + PlayerRoster.TeamT, + Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)), + warmup: false + ); + roster.Mvp("1", warmup: false); + + RosterReport report = roster.Report(At(2)); + + Assert.Equal((1, 1), Rounds(report, "1")); + Assert.Equal(1, CountersOf(report, "1").Mvps); + Assert.Equal((1, 0), Rounds(report, "2")); + } + + // A changelevel cuts the round under way short, and the new map's first + // round has not gone live until its freeze time ends. + [Fact] + public void ARoundTheMapChangeCutShortIsNotCounted() + { + PlayerRoster roster = new(); + roster.MapIs("de_mirage"); + roster.Observe([Human("1")], Start); + roster.FreezeTimeEnded(warmup: false); + + roster.MapEnded(At(1)); + roster.MapStarted(At(2), "de_inferno"); + roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: false); + roster.Mvp("1", warmup: false); + + Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(3)), "1")); + } + [Fact] public void AnMvpGoesToARosterPlayerOnly() { PlayerRoster roster = new(); roster.Observe([Human("1")], Start); + roster.FreezeTimeEnded(warmup: false); roster.Mvp("1", warmup: false); roster.Mvp("1", warmup: false); @@ -1319,6 +1377,7 @@ public void AnMvpGoesToARosterPlayerOnly() private static void CountEverything(PlayerRoster roster, string steamId, string enemy) { + roster.FreezeTimeEnded(warmup: false); roster.Died(enemy, steamId, warmup: false, assister: null, headshot: true); roster.Died(steamId, enemy, warmup: false); roster.Died(enemy, null, warmup: false, assister: steamId); diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 67363875..f3e220c3 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -82,6 +82,7 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private string? _map; private RosterReport? _final; private DateTimeOffset _finalAt; + private bool _roundLive; // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) @@ -176,6 +177,9 @@ public void MapStarted(DateTimeOffset now, string? map) Hold(now); _mapStarted = true; _releaseAt = null; + + // A round the map change cut short never ends. + _roundLive = false; } } @@ -266,6 +270,18 @@ public void Hurt(string? attacker, string? victim, int damage, bool warmup) } } + // Ending warmup ends its round as a win for whoever is alive, so the warmup + // flag at a round's end is not what decides it: a round only counts once + // its freeze time has ended outside warmup, as the match plugin's knife + // round does. + public void FreezeTimeEnded(bool warmup) + { + lock (_lock) + { + _roundLive = !warmup; + } + } + // A round is played by whoever is on a side when it ends, so a player who // switches sides mid-round plays it for the side they ended it on, and a // spectator plays none. Warmup rounds are not rounds. @@ -282,6 +298,11 @@ bool warmup lock (_lock) { + if (!_roundLive) + { + return; + } + foreach ((string steamId, int team) in teamsBySteamId) { if ( @@ -302,6 +323,7 @@ bool warmup } } + // A round that never went live has no MVP either. public void Mvp(string? steamId, bool warmup) { if (warmup) @@ -311,7 +333,11 @@ public void Mvp(string? steamId, bool warmup) lock (_lock) { - if (steamId != null && _current.TryGetValue(steamId, out Entry? entry)) + if ( + _roundLive + && steamId != null + && _current.TryGetValue(steamId, out Entry? entry) + ) { entry.Mvps++; } From ef9b76ffc1df65ebef8079d88e74da42e29c92d0 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 14:37:06 +0100 Subject: [PATCH 08/13] bug: a round counts once, and the flag that lets it count goes at the next round's start, so a second round end before then adds nothing --- .../src/PlayerManagementPlugin.cs | 15 ++++++++++ .../src/PlayerManagementPlugin.cs | 15 ++++++++++ .../test/PlayerRosterTests.cs | 30 +++++++++++++++++++ .../FiveStack.Utilities/PlayerRoster.cs | 19 +++++++++++- 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index a841f80b..2dc800a0 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -515,6 +515,21 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) return HookResult.Continue; } + [GameEventHandler] + public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) + { + try + { + _roster.RoundStarted(); + } + catch (Exception error) + { + Logger.LogError(error, "unable to start a round"); + } + + return HookResult.Continue; + } + [GameEventHandler] public HookResult OnRoundFreezeEnd(EventRoundFreezeEnd @event, GameEventInfo info) { diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index e84d5438..63196c0c 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -567,6 +567,21 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) return HookResult.Continue; } + [GameEventHandler(HookMode.Post)] + public HookResult OnRoundStart(EventRoundStart @event) + { + try + { + _roster.RoundStarted(); + } + catch (Exception error) + { + _logger.LogError(error, "unable to start a round"); + } + + return HookResult.Continue; + } + [GameEventHandler(HookMode.Post)] public HookResult OnRoundFreezeEnd(EventRoundFreezeEnd @event) { diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 00dfc35e..39a0244e 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1342,6 +1342,36 @@ public void EndingWarmupHandsOutNoRound() Assert.Equal((1, 0), Rounds(report, "2")); } + // An admin or a plugin can end a round again before the next one starts, + // and the next round only counts once its own freeze time has ended. + [Fact] + public void ARoundCountsOnceAndTheNextOneOnlyOnceItGoesLive() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + IReadOnlyDictionary teams = Teams( + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamCT) + ); + + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + roster.Mvp("1", warmup: false); + roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false); + + Assert.Equal((1, 1), Rounds(roster.Report(At(1)), "1")); + Assert.Equal((1, 0), Rounds(roster.Report(At(1)), "2")); + + roster.RoundStarted(); + roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false); + roster.Mvp("2", warmup: false); + + RosterReport report = roster.Report(At(2)); + Assert.Equal((1, 0), Rounds(report, "2")); + Assert.Equal(0, CountersOf(report, "2").Mvps); + Assert.Equal(1, CountersOf(report, "1").Mvps); + } + // A changelevel cuts the round under way short, and the new map's first // round has not gone live until its freeze time ends. [Fact] diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index f3e220c3..d583d511 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -83,6 +83,7 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private RosterReport? _final; private DateTimeOffset _finalAt; private bool _roundLive; + private bool _roundEnded; // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) @@ -279,6 +280,20 @@ public void FreezeTimeEnded(bool warmup) lock (_lock) { _roundLive = !warmup; + _roundEnded = false; + } + } + + // A round counts once: another round end before the next round starts (an + // admin ending the round, a plugin terminating it) is not another round. + // The MVP can come after the round end, so the flag only goes at the + // next round's start. + public void RoundStarted() + { + lock (_lock) + { + _roundLive = false; + _roundEnded = false; } } @@ -298,11 +313,13 @@ bool warmup lock (_lock) { - if (!_roundLive) + if (!_roundLive || _roundEnded) { return; } + _roundEnded = true; + foreach ((string steamId, int team) in teamsBySteamId) { if ( From 0d95445eea29e00e95a6854e9d976668faade99e Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 17:06:02 +0100 Subject: [PATCH 09/13] bug: a round ended again has had its MVP, and the map change test checks the new map's roster, not the snapshot taken at the map's end --- .../test/PlayerRosterTests.cs | 39 ++++++++++++++++++- .../FiveStack.Utilities/PlayerRoster.cs | 13 ++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 39a0244e..f7961408 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1383,11 +1383,48 @@ public void ARoundTheMapChangeCutShortIsNotCounted() roster.FreezeTimeEnded(warmup: false); roster.MapEnded(At(1)); + roster.Delivered(roster.Report(At(1)), true); roster.MapStarted(At(2), "de_inferno"); + roster.Observe([Human("1")], At(3)); roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: false); roster.Mvp("1", warmup: false); - Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(3)), "1")); + // The old map's report is delivered and everyone held is back, so this + // is the new map's roster and not the snapshot taken at the map's end. + RosterReport report = roster.Report(At(4)); + Assert.NotNull(report.Players); + Assert.Equal("de_inferno", report.Map); + Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(report, "1")); + } + + // The MVP comes after the round end, and a round ended again has had its + // MVP, whichever order the two ends and the two MVPs come in. + [Fact] + public void ARoundEndedAgainGetsNoSecondMvp() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + IReadOnlyDictionary teams = Teams( + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamCT) + ); + + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + roster.Mvp("1", warmup: false); + roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false); + roster.Mvp("2", warmup: false); + + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + roster.Mvp("2", warmup: false); + roster.Mvp("1", warmup: false); + + RosterReport report = roster.Report(At(1)); + Assert.Equal(1, CountersOf(report, "1").Mvps); + Assert.Equal(1, CountersOf(report, "2").Mvps); } [Fact] diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index d583d511..c490d7e1 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -84,6 +84,7 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private DateTimeOffset _finalAt; private bool _roundLive; private bool _roundEnded; + private bool _roundMvp; // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) @@ -281,6 +282,7 @@ public void FreezeTimeEnded(bool warmup) { _roundLive = !warmup; _roundEnded = false; + _roundMvp = false; } } @@ -294,6 +296,7 @@ public void RoundStarted() { _roundLive = false; _roundEnded = false; + _roundMvp = false; } } @@ -340,7 +343,8 @@ bool warmup } } - // A round that never went live has no MVP either. + // A round that never went live has no MVP either, and a round ended again + // has had its MVP: the MVP after its first end is the only one it gets. public void Mvp(string? steamId, bool warmup) { if (warmup) @@ -350,6 +354,13 @@ public void Mvp(string? steamId, bool warmup) lock (_lock) { + if (_roundEnded && _roundMvp) + { + return; + } + + _roundMvp = _roundEnded; + if ( _roundLive && steamId != null From 681515fd4a0bbdd217705b394b7e78d33f6a84ec Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 17:10:03 +0100 Subject: [PATCH 10/13] bug: an MVP to a bot or to a player who has gone reaches the roster too, so it closes the round's MVP like any other --- .../src/PlayerManagementPlugin.cs | 10 ++++++---- .../player-management-sw/src/PlayerManagementPlugin.cs | 10 ++++++---- apps/player-management-sw/test/PlayerRosterTests.cs | 10 +++++++++- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 2dc800a0..c3f7476e 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -586,10 +586,12 @@ public HookResult OnRoundMvp(EventRoundMvp @event, GameEventInfo info) { CCSPlayerController? player = @event.Userid; - if (player != null && IsHuman(player)) - { - _roster.Mvp(VerifiedSteamIdOf(player), IsWarmup()); - } + // A bot's MVP, or one whose player has gone, is still the round's + // MVP, so it reaches the roster without a player to credit. + _roster.Mvp( + player != null && IsHuman(player) ? VerifiedSteamIdOf(player) : null, + IsWarmup() + ); } catch (Exception error) { diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 63196c0c..bf241c65 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -638,10 +638,12 @@ public HookResult OnRoundMvp(EventRoundMvp @event) { IPlayer? player = @event.UserIdPlayer; - if (player != null && IsHuman(player)) - { - _roster.Mvp(VerifiedSteamIdOf(player), IsWarmup()); - } + // A bot's MVP, or one whose player has gone, is still the round's + // MVP, so it reaches the roster without a player to credit. + _roster.Mvp( + player != null && IsHuman(player) ? VerifiedSteamIdOf(player) : null, + IsWarmup() + ); } catch (Exception error) { diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index f7961408..7eaf9d7d 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1398,7 +1398,8 @@ public void ARoundTheMapChangeCutShortIsNotCounted() } // The MVP comes after the round end, and a round ended again has had its - // MVP, whichever order the two ends and the two MVPs come in. + // MVP, whether the second end comes before or after the first MVP and + // whether that MVP went to a bot. [Fact] public void ARoundEndedAgainGetsNoSecondMvp() { @@ -1422,6 +1423,13 @@ public void ARoundEndedAgainGetsNoSecondMvp() roster.Mvp("2", warmup: false); roster.Mvp("1", warmup: false); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + roster.Mvp(null, warmup: false); + roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false); + roster.Mvp("2", warmup: false); + RosterReport report = roster.Report(At(1)); Assert.Equal(1, CountersOf(report, "1").Mvps); Assert.Equal(1, CountersOf(report, "2").Mvps); From 12b87f07e65685d93b14b55a875442ecfd36688b Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:25:01 +0100 Subject: [PATCH 11/13] bug: a round only goes to players who spawned in it and end it on the side they spawned on, so sitting on a side or joining the winning one farms no rounds --- .../src/PlayerManagementPlugin.cs | 27 ++++- .../src/PlayerManagementPlugin.cs | 27 ++++- .../test/PlayerRosterTests.cs | 102 +++++++++++++++++- .../FiveStack.Utilities/PlayerRoster.cs | 81 ++++++++++---- 4 files changed, 210 insertions(+), 27 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index c3f7476e..10ffd2fd 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -161,8 +161,13 @@ public override void Load(bool hotReload) Logger.LogDebug(error, "unable to read the map on load"); } - // Nor has it seen the round under way go live. + // Nor has it seen the round under way go live, or anyone in it spawn. _roster.FreezeTimeEnded(IsWarmup()); + + foreach (CCSPlayerController player in Humans()) + { + CountSpawn(player); + } } _loop.Start(); @@ -505,6 +510,11 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) if (player != null) { _health.Spawned(player.Slot); + + // Joining a side fires a spawn too, for a player who stays dead + // until the next round, so whether they are alive is only read + // once the spawn is over. + Server.NextFrame(() => CountSpawn(player)); } } catch (Exception error) @@ -515,6 +525,21 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) return HookResult.Continue; } + private void CountSpawn(CCSPlayerController player) + { + try + { + if (IsHuman(player) && player.PawnIsAlive) + { + _roster.Spawned(VerifiedSteamIdOf(player), player.TeamNum); + } + } + catch (Exception error) + { + Logger.LogError(error, "unable to count a spawn"); + } + } + [GameEventHandler] public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) { diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index bf241c65..66057c14 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -189,8 +189,13 @@ public override void Load(bool hotReload) _logger.LogDebug(error, "unable to read the map on load"); } - // Nor has it seen the round under way go live. + // Nor has it seen the round under way go live, or anyone in it spawn. _roster.FreezeTimeEnded(IsWarmup()); + + foreach (IPlayer player in Humans()) + { + CountSpawn(player); + } } _loop.Start(); @@ -557,6 +562,11 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) if (player != null) { _health.Spawned(player.PlayerID); + + // Joining a side fires a spawn too, for a player who stays dead + // until the next round, so whether they are alive is only read + // once the spawn is over. + Core.Scheduler.NextTick(() => CountSpawn(player)); } } catch (Exception error) @@ -567,6 +577,21 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) return HookResult.Continue; } + private void CountSpawn(IPlayer player) + { + try + { + if (IsHuman(player) && player.IsAlive) + { + _roster.Spawned(VerifiedSteamIdOf(player), player.Controller.TeamNum); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to count a spawn"); + } + } + [GameEventHandler(HookMode.Post)] public HookResult OnRoundStart(EventRoundStart @event) { diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 7eaf9d7d..165972b8 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1213,12 +1213,18 @@ int expected } // Player 1 switches sides between the rounds and wins both: a round goes to - // the side a player ends it on. + // the side a player spawned on and ends it on. [Fact] - public void ARoundIsPlayedByEveryoneOnASideAndWonByTheWinners() + public void ARoundIsPlayedByEveryoneWhoSpawnedOnASideAndWonByTheWinners() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2"), Human("3"), Human("4")], Start); + Spawn( + roster, + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamCT), + ("bot", PlayerRoster.TeamT) + ); roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( @@ -1232,6 +1238,12 @@ public void ARoundIsPlayedByEveryoneOnASideAndWonByTheWinners() ), warmup: false ); + Spawn( + roster, + ("1", PlayerRoster.TeamCT), + ("2", PlayerRoster.TeamCT), + ("3", PlayerRoster.TeamT) + ); roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( PlayerRoster.TeamCT, @@ -1257,6 +1269,7 @@ public void ADrawIsPlayedAndWonByNobody() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2")], Start); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( @@ -1276,6 +1289,7 @@ public void WarmupRoundsAreNotCounted() { PlayerRoster roster = new(); roster.Observe([Human("1")], Start); + Spawn(roster, ("1", PlayerRoster.TeamT)); roster.FreezeTimeEnded(warmup: true); roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true); @@ -1316,6 +1330,7 @@ public void EndingWarmupHandsOutNoRound() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2")], Start); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); roster.FreezeTimeEnded(warmup: true); roster.RoundEnded( @@ -1327,6 +1342,7 @@ public void EndingWarmupHandsOutNoRound() Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(1)), "1")); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); roster.FreezeTimeEnded(warmup: false); roster.RoundEnded( PlayerRoster.TeamT, @@ -1354,6 +1370,7 @@ public void ARoundCountsOnceAndTheNextOneOnlyOnceItGoesLive() ("2", PlayerRoster.TeamCT) ); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); roster.FreezeTimeEnded(warmup: false); roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); roster.Mvp("1", warmup: false); @@ -1363,6 +1380,7 @@ public void ARoundCountsOnceAndTheNextOneOnlyOnceItGoesLive() Assert.Equal((1, 0), Rounds(roster.Report(At(1)), "2")); roster.RoundStarted(); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false); roster.Mvp("2", warmup: false); @@ -1386,6 +1404,7 @@ public void ARoundTheMapChangeCutShortIsNotCounted() roster.Delivered(roster.Report(At(1)), true); roster.MapStarted(At(2), "de_inferno"); roster.Observe([Human("1")], At(3)); + Spawn(roster, ("1", PlayerRoster.TeamT)); roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: false); roster.Mvp("1", warmup: false); @@ -1450,8 +1469,87 @@ public void AnMvpGoesToARosterPlayerOnly() Assert.Equal(2, CountersOf(roster.Report(At(1)), "1").Mvps); } + // Sitting on a side without spawning, or joining the side about to win + // after spawning on the other, plays no round. A spawn before the round + // starts counts, as does a late one, and a spawn is only good for the + // round it was in. + [Fact] + public void ARoundIsOnlyPlayedByWhoeverSpawnedInItOnTheSideTheyEndedOn() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3"), Human("4")], Start); + IReadOnlyDictionary teams = Teams( + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamT), + ("3", PlayerRoster.TeamT), + ("4", PlayerRoster.TeamCT) + ); + + Spawn(roster, ("1", PlayerRoster.TeamT), ("3", PlayerRoster.TeamCT)); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + Spawn(roster, ("4", PlayerRoster.TeamCT)); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + RosterReport first = roster.Report(At(1)); + Assert.Equal((1, 1), Rounds(first, "1")); + Assert.Equal((0, 0), Rounds(first, "2")); + Assert.Equal((0, 0), Rounds(first, "3")); + Assert.Equal((1, 0), Rounds(first, "4")); + + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + RosterReport second = roster.Report(At(2)); + Assert.Equal((1, 1), Rounds(second, "1")); + Assert.Equal((1, 0), Rounds(second, "4")); + } + + // The spawns of a round the map change cut short, or of warmup, are not + // carried into the next round. + [Fact] + public void SpawnsAreNotCarriedPastAMapChangeOrAWarmupRound() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + IReadOnlyDictionary teams = Teams(("1", PlayerRoster.TeamT)); + + Spawn(roster, ("1", PlayerRoster.TeamT)); + roster.MapEnded(At(1)); + roster.Delivered(roster.Report(At(1)), true); + roster.MapStarted(At(2), "de_inferno"); + roster.Observe([Human("1")], At(3)); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + Spawn(roster, ("1", PlayerRoster.TeamT)); + roster.FreezeTimeEnded(warmup: true); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: true); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + Assert.Equal((0, 0), Rounds(roster.Report(At(4)), "1")); + + Spawn(roster, ("1", PlayerRoster.TeamT)); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + Assert.Equal((1, 1), Rounds(roster.Report(At(5)), "1")); + } + + private static void Spawn(PlayerRoster roster, params (string SteamId, int Team)[] spawns) + { + foreach ((string steamId, int team) in spawns) + { + roster.Spawned(steamId, team); + } + } + private static void CountEverything(PlayerRoster roster, string steamId, string enemy) { + Spawn(roster, (steamId, PlayerRoster.TeamT), (enemy, PlayerRoster.TeamCT)); roster.FreezeTimeEnded(warmup: false); roster.Died(enemy, steamId, warmup: false, assister: null, headshot: true); roster.Died(steamId, enemy, warmup: false); diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index c490d7e1..a29880dd 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -86,6 +86,9 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private bool _roundEnded; private bool _roundMvp; + // The side each player last spawned on since the last round ended. + private readonly Dictionary _spawned = new(); + // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) { @@ -182,6 +185,7 @@ public void MapStarted(DateTimeOffset now, string? map) // A round the map change cut short never ends. _roundLive = false; + _spawned.Clear(); } } @@ -300,45 +304,76 @@ public void RoundStarted() } } - // A round is played by whoever is on a side when it ends, so a player who - // switches sides mid-round plays it for the side they ended it on, and a - // spectator plays none. Warmup rounds are not rounds. + // Spawns are kept from one round's end to the next rather than from the + // round's start, so they hold whichever of round_start and the players' + // spawns comes first, and a player who spawns late in the round (joining + // in buy time) is in it too. + public void Spawned(string? steamId, int team) + { + if (steamId == null) + { + return; + } + + lock (_lock) + { + _spawned[steamId] = team; + } + } + + // A round is played by whoever spawned in it and ends it on the side they + // last spawned on. Sitting on a side without spawning, or switching to the + // side about to win, plays no round, and a spectator plays none. Warmup + // rounds are not rounds. public void RoundEnded( int winnerTeam, IReadOnlyDictionary teamsBySteamId, bool warmup ) { - if (warmup) + lock (_lock) + { + try + { + Credit(winnerTeam, teamsBySteamId, warmup); + } + finally + { + _spawned.Clear(); + } + } + } + + private void Credit( + int winnerTeam, + IReadOnlyDictionary teamsBySteamId, + bool warmup + ) + { + if (warmup || !_roundLive || _roundEnded) { return; } - lock (_lock) + _roundEnded = true; + + foreach ((string steamId, int team) in teamsBySteamId) { - if (!_roundLive || _roundEnded) + if ( + (team != TeamT && team != TeamCT) + || !_spawned.TryGetValue(steamId, out int spawnedOn) + || spawnedOn != team + || !_current.TryGetValue(steamId, out Entry? entry) + ) { - return; + continue; } - _roundEnded = true; + entry.RoundsPlayed++; - foreach ((string steamId, int team) in teamsBySteamId) + if (team == winnerTeam) { - if ( - (team != TeamT && team != TeamCT) - || !_current.TryGetValue(steamId, out Entry? entry) - ) - { - continue; - } - - entry.RoundsPlayed++; - - if (team == winnerTeam) - { - entry.RoundsWon++; - } + entry.RoundsWon++; } } } From fdd2d91c8de1078fdb5cd16b25aac1eba861f720 Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:33:25 +0100 Subject: [PATCH 12/13] bug: a spawn is read off the player's own pawn and kept under the id they claim, and leaving a side or the game restarting forgets it --- .../src/PlayerManagementPlugin.cs | 42 +++++++++-- .../src/PlayerManagementPlugin.cs | 48 ++++++++++-- .../test/PlayerRosterTests.cs | 74 +++++++++++++++++++ .../FiveStack.Utilities/PlayerRoster.cs | 27 +++++++ 4 files changed, 181 insertions(+), 10 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 10ffd2fd..8474676a 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -161,7 +161,9 @@ public override void Load(bool hotReload) Logger.LogDebug(error, "unable to read the map on load"); } - // Nor has it seen the round under way go live, or anyone in it spawn. + // Nor has it seen the round under way go live, or anyone in it spawn: + // whoever is alive now is in it, but whoever already died in it is + // not. _roster.FreezeTimeEnded(IsWarmup()); foreach (CCSPlayerController player in Humans()) @@ -513,25 +515,34 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) // Joining a side fires a spawn too, for a player who stays dead // until the next round, so whether they are alive is only read - // once the spawn is over. + // once the spawn is over. A spawn in the frame the round ends + // is read after it, and so goes to the next round. Server.NextFrame(() => CountSpawn(player)); } } catch (Exception error) { - Logger.LogError(error, "unable to reset a player's health"); + Logger.LogError(error, "unable to reset a player's health or count their spawn"); } return HookResult.Continue; } + // Read off the player's own pawn rather than a flag the controller keeps + // for it. A spawn is kept under the id the player claims, so one who spawns + // before Steam verifies them is in the round; only a verified id is + // credited for it at the round's end. private void CountSpawn(CCSPlayerController player) { try { - if (IsHuman(player) && player.PawnIsAlive) + if ( + IsHuman(player) + && player.PlayerPawn.Value is { IsValid: true } pawn + && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE + ) { - _roster.Spawned(VerifiedSteamIdOf(player), player.TeamNum); + _roster.Spawned(SteamIdOf(player).ToString(), player.TeamNum); } } catch (Exception error) @@ -540,6 +551,26 @@ private void CountSpawn(CCSPlayerController player) } } + [GameEventHandler] + public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) + { + try + { + CCSPlayerController? player = @event.Userid; + + if (player != null && IsHuman(player) && @event.Team != @event.Oldteam) + { + _roster.LeftSide(SteamIdOf(player).ToString()); + } + } + catch (Exception error) + { + Logger.LogError(error, "unable to forget a spawn on a team change"); + } + + return HookResult.Continue; + } + [GameEventHandler] public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) { @@ -579,6 +610,7 @@ public HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info) // round anybody played. if (@event.Message == "#SFUI_Notice_Game_Commencing") { + _roster.GameCommencing(); return HookResult.Continue; } diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 66057c14..20441658 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -189,7 +189,9 @@ public override void Load(bool hotReload) _logger.LogDebug(error, "unable to read the map on load"); } - // Nor has it seen the round under way go live, or anyone in it spawn. + // Nor has it seen the round under way go live, or anyone in it spawn: + // whoever is alive now is in it, but whoever already died in it is + // not. _roster.FreezeTimeEnded(IsWarmup()); foreach (IPlayer player in Humans()) @@ -565,25 +567,35 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) // Joining a side fires a spawn too, for a player who stays dead // until the next round, so whether they are alive is only read - // once the spawn is over. + // once the spawn is over. A spawn in the tick the round ends + // is read after it, and so goes to the next round. Core.Scheduler.NextTick(() => CountSpawn(player)); } } catch (Exception error) { - _logger.LogError(error, "unable to reset a player's health"); + _logger.LogError(error, "unable to reset a player's health or count their spawn"); } return HookResult.Continue; } + // Read off the player's own pawn: a dead player's current pawn is the one + // they watch the round through. A spawn is kept under the id the player + // claims, so one who spawns before Steam verifies them is in the round; + // only a verified id is credited for it at the round's end. private void CountSpawn(IPlayer player) { try { - if (IsHuman(player) && player.IsAlive) + if ( + player.IsValid + && IsHuman(player) + && player.PlayerPawn is { IsValid: true } pawn + && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE + ) { - _roster.Spawned(VerifiedSteamIdOf(player), player.Controller.TeamNum); + _roster.Spawned(SteamIdOf(player).ToString(), player.Controller.TeamNum); } } catch (Exception error) @@ -592,6 +604,31 @@ private void CountSpawn(IPlayer player) } } + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerTeam(EventPlayerTeam @event) + { + try + { + IPlayer? player = @event.UserIdPlayer; + + if ( + player != null + && player.IsValid + && IsHuman(player) + && @event.Team != @event.OldTeam + ) + { + _roster.LeftSide(SteamIdOf(player).ToString()); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to forget a spawn on a team change"); + } + + return HookResult.Continue; + } + [GameEventHandler(HookMode.Post)] public HookResult OnRoundStart(EventRoundStart @event) { @@ -631,6 +668,7 @@ public HookResult OnRoundEnd(EventRoundEnd @event) // round anybody played. if (@event.Message == "#SFUI_Notice_Game_Commencing") { + _roster.GameCommencing(); return HookResult.Continue; } diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 165972b8..0f06049a 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1539,6 +1539,80 @@ public void SpawnsAreNotCarriedPastAMapChangeOrAWarmupRound() Assert.Equal((1, 1), Rounds(roster.Report(At(5)), "1")); } + // Leaving a side forgets the spawn on it, so going spectator and coming + // back to the same side dead plays no round, nor does a spawn from before + // the game restarted. Spawning again does. + [Fact] + public void LeavingASideOrARestartForgetsTheSpawnsBeforeIt() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + IReadOnlyDictionary teams = Teams( + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamT) + ); + + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamT)); + roster.LeftSide("1"); + roster.LeftSide(null); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + RosterReport first = roster.Report(At(1)); + Assert.Equal((0, 0), Rounds(first, "1")); + Assert.Equal((1, 1), Rounds(first, "2")); + + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamT)); + roster.GameCommencing(); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + Spawn(roster, ("2", PlayerRoster.TeamT)); + roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false); + + RosterReport second = roster.Report(At(2)); + Assert.Equal((0, 0), Rounds(second, "1")); + Assert.Equal((2, 2), Rounds(second, "2")); + } + + // Only the side a player last spawned on counts, so a side swap at the + // half plays the next round on the new side, and a spawn under no id is + // nobody's. + [Fact] + public void ARoundGoesToTheSideAPlayerLastSpawnedOn() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + Spawn( + roster, + ("1", PlayerRoster.TeamCT), + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamT) + ); + roster.Spawned(null, PlayerRoster.TeamCT); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded( + PlayerRoster.TeamT, + Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamT)), + warmup: false + ); + + Spawn(roster, ("1", PlayerRoster.TeamCT), ("2", PlayerRoster.TeamCT)); + roster.RoundStarted(); + roster.FreezeTimeEnded(warmup: false); + roster.RoundEnded( + PlayerRoster.TeamCT, + Teams(("1", PlayerRoster.TeamCT), ("2", PlayerRoster.TeamT)), + warmup: false + ); + + RosterReport report = roster.Report(At(1)); + Assert.Equal((2, 2), Rounds(report, "1")); + Assert.Equal((1, 1), Rounds(report, "2")); + } + private static void Spawn(PlayerRoster roster, params (string SteamId, int Team)[] spawns) { foreach ((string steamId, int team) in spawns) diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index a29880dd..3eb61211 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -321,6 +321,33 @@ public void Spawned(string? steamId, int team) } } + // A spawn only holds for the side it was on: a player who leaves it, for + // the other side or for spectators, has to spawn again to play the round, + // so going spectator and coming back dead after a restart that ended no + // round plays none. + public void LeftSide(string? steamId) + { + if (steamId == null) + { + return; + } + + lock (_lock) + { + _spawned.Remove(steamId); + } + } + + // The game starting or restarting ends no round anybody played, but the + // spawns before it are not in the round after it either. + public void GameCommencing() + { + lock (_lock) + { + _spawned.Clear(); + } + } + // A round is played by whoever spawned in it and ends it on the side they // last spawned on. Sitting on a side without spawning, or switching to the // side about to win, plays no round, and a spectator plays none. Warmup From 713eaf2923859990ab98c6a9a194264a747c24ba Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:35:59 +0100 Subject: [PATCH 13/13] bug: a player leaving the server forgets their spawn on sw too, as on css --- apps/player-management-sw/src/PlayerManagementPlugin.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 20441658..a6dcdb6a 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -611,10 +611,13 @@ public HookResult OnPlayerTeam(EventPlayerTeam @event) { IPlayer? player = @event.UserIdPlayer; + // Not IsHuman: a player leaving the server is already no longer + // connected when their team goes, and leaving it leaves the side. if ( player != null && player.IsValid - && IsHuman(player) + && !player.IsFakeClient + && SteamIdOf(player) != 0 && @event.Team != @event.OldTeam ) {