From ebe260514543415d5a1dd56939f579e2d80f1f36 Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 30 Oct 2026 00:06:18 +0100 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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 6d032a077c7c115b0afa0fdf0b21c27069ef7c5f Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 31 Oct 2026 23:32:53 +0100 Subject: [PATCH 05/18] feature: Player Management counts shots, hits, utility, kill types, trades, objectives, the best killstreak and per weapon totals for the community stats Each connection now keeps 33 detailed counters, its best killstreak and per weapon totals (kills, headshots, shots, hits and damage) next to the counters it already had, reset with a new connection like those, and sends them as `stats` and `weapons` on every roster and departed entry. Zeros are left out and an empty map is not sent at all, so panels that predate the detailed stats see the report as before. The counters take the match stats' names and rules: shots are firearms only, a shotgun's pellets are one hit per victim and tick, a flash counts when it blinds an enemy for more than a second, a trade is the kill of a teammate's killer within 5 s in the same round, and utility damage is capped at the health the victim had. Every knife is one knife and a silenced gun is one gun with its silencer on or off. Nothing done in warmup counts, as before. Both plugins pass the kill facts on player_death and the weapon, hitgroup and tick on player_hurt, and hook weapon_fire, player_blind, bomb_planted, bomb_defused, hostage_rescued and round_start. --- .../src/PlayerManagement.csproj | 3 + .../src/PlayerManagementPlugin.cs | 269 ++++- .../src/PlayerManagement.csproj | 3 + .../src/PlayerManagementPlugin.cs | 269 ++++- .../test/CommunityTradesTests.cs | 124 +++ .../test/CommunityWeaponsTests.cs | 140 +++ .../test/FiveStack.Tests.csproj | 3 + .../test/PlayerRosterTests.cs | 952 ++++++++++++++++++ .../PlayerManagement/PlayerSanction.cs | 37 + .../FiveStack.Utilities/CommunityStatKeys.cs | 98 ++ .../FiveStack.Utilities/CommunityTrades.cs | 49 + .../FiveStack.Utilities/CommunityWeapons.cs | 125 +++ .../FiveStack.Utilities/PlayerRoster.cs | 475 ++++++++- 13 files changed, 2504 insertions(+), 43 deletions(-) create mode 100644 apps/player-management-sw/test/CommunityTradesTests.cs create mode 100644 apps/player-management-sw/test/CommunityWeaponsTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/CommunityStatKeys.cs create mode 100644 shared/dotnet/FiveStack.Utilities/CommunityTrades.cs create mode 100644 shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index 14d73b9b..e3eca48e 100644 --- a/apps/player-management-css/src/PlayerManagement.csproj +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -20,6 +20,9 @@ + + + diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index ec3d8f56..e3558af9 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -431,12 +431,59 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) @event.Assistedflash ); + // A flash assist is counted apart, by the same rule as a kill. + bool flashAssist = + @event.Assistedflash + && assister != null + && IsHuman(assister) + && PlayerRoster.CountsAsKill( + assister.Slot, + victim.Slot, + assister.TeamNum, + victim.TeamNum, + teammatesAreEnemies + ); + + bool teamKill = + attacker != null + && IsHuman(attacker) + && PlayerRoster.IsTeamKill( + attacker.Slot, + victim.Slot, + attacker.TeamNum, + victim.TeamNum, + teammatesAreEnemies + ); + + KillFacts facts = new( + Weapon: @event.Weapon, + Time: Server.CurrentTime, + KillerSlot: kill ? attacker!.Slot : -1, + KillerTeam: kill ? attacker!.TeamNum : 0, + VictimSlot: victim.Slot, + VictimTeam: victim.TeamNum, + TeammatesAreEnemies: teammatesAreEnemies, + NoScope: @event.Noscope, + ThruSmoke: @event.Thrusmoke, + AttackerBlind: @event.Attackerblind, + Penetrated: @event.Penetrated, + AttackerInAir: @event.Attackerinair, + AssistedFlash: flashAssist, + Suicide: PlayerRoster.IsSuicide( + attacker != null && attacker.IsValid ? attacker.Slot : null, + victim.Slot, + @event.Weapon + ), + TeamKiller: teamKill ? VerifiedSteamIdOf(attacker!) : null + ); + _roster.Died( VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null, IsWarmup(), - assist ? VerifiedSteamIdOf(assister!) : null, - @event.Headshot + assist || flashAssist ? VerifiedSteamIdOf(assister!) : null, + @event.Headshot, + facts ); } catch (Exception error) @@ -454,7 +501,7 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) { CCSPlayerController? victim = @event.Userid; - if (victim == null || !IsHuman(victim)) + if (victim == null || !victim.IsValid) { return HookResult.Continue; } @@ -466,28 +513,50 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) ); _health[victim.Slot] = @event.Health; + // A hit on a bot counts, damage to one does not, and only a human + // hurting a human who is no enemy is team damage. Nothing counts + // without an attacker, between two bots, or for a bot hurt by + // anything but a gun, which spares those the lookups below. CCSPlayerController? attacker = @event.Attacker; - if ( - attacker == null - || !IsHuman(attacker) - || !PlayerRoster.CountsAsKill( - attacker.Slot, - victim.Slot, - attacker.TeamNum, - victim.TeamNum, - TeammatesAreEnemies() - ) - ) + if (attacker == null || !attacker.IsValid) + { + return HookResult.Continue; + } + + bool attackerIsHuman = IsHuman(attacker); + bool victimIsHuman = IsHuman(victim); + + if (!victimIsHuman && (!attackerIsHuman || !CommunityWeapons.IsFirearm(@event.Weapon))) { return HookResult.Continue; } + bool teammatesAreEnemies = TeammatesAreEnemies(); + + bool enemy = PlayerRoster.CountsAsKill( + attacker.Slot, + victim.Slot, + attacker.TeamNum, + victim.TeamNum, + teammatesAreEnemies + ); + _roster.Hurt( - VerifiedSteamIdOf(attacker), - VerifiedSteamIdOf(victim), + attackerIsHuman ? VerifiedSteamIdOf(attacker) : null, + victimIsHuman ? VerifiedSteamIdOf(victim) : null, dealt, - IsWarmup() + IsWarmup(), + new HitFacts( + @event.Weapon, + @event.Hitgroup, + Server.TickCount, + victim.Slot, + victimIsHuman, + enemy, + attackerIsHuman && victimIsHuman && !enemy, + teammatesAreEnemies + ) ); } catch (Exception error) @@ -518,6 +587,134 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) return HookResult.Continue; } + [GameEventHandler] + public HookResult OnWeaponFire(EventWeaponFire @event, GameEventInfo info) + { + try + { + // This runs for every shot and every throw, so the cheap checks + // come before the lookups. + if (!CommunityWeapons.IsFirearm(@event.Weapon)) + { + return HookResult.Continue; + } + + CCSPlayerController? player = @event.Userid; + + if (player == null || !IsHuman(player)) + { + return HookResult.Continue; + } + + string? steamId = VerifiedSteamIdOf(player); + + if (steamId == null) + { + return HookResult.Continue; + } + + _roster.Fired(steamId, FiredWeapon(player, @event.Weapon), IsWarmup()); + } + catch (Exception error) + { + Logger.LogError(error, "unable to count a shot"); + } + + return HookResult.Continue; + } + + // Blinding oneself is blinding a teammate, as it is on the match side. + [GameEventHandler] + public HookResult OnPlayerBlind(EventPlayerBlind @event, GameEventInfo info) + { + try + { + CCSPlayerController? victim = @event.Userid; + CCSPlayerController? attacker = @event.Attacker; + + if (victim == null || !IsHuman(victim) || attacker == null || !IsHuman(attacker)) + { + return HookResult.Continue; + } + + // A spectator or a dead player can be blinded too, and counts for + // nothing. + int victimTeam = victim.TeamNum; + + if ( + (victimTeam != PlayerRoster.TeamT && victimTeam != PlayerRoster.TeamCT) + || !victim.PawnIsAlive + ) + { + return HookResult.Continue; + } + + bool teammatesAreEnemies = TeammatesAreEnemies(); + + bool sameTeam = !PlayerRoster.CountsAsKill( + attacker.Slot, + victim.Slot, + attacker.TeamNum, + victimTeam, + teammatesAreEnemies + ); + + _roster.Blinded( + VerifiedSteamIdOf(attacker), + VerifiedSteamIdOf(victim), + @event.BlindDuration, + sameTeam, + IsWarmup(), + teammatesAreEnemies + ); + } + catch (Exception error) + { + Logger.LogError(error, "unable to count a flash"); + } + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnBombPlanted(EventBombPlanted @event, GameEventInfo info) + { + Objective(@event.Userid, CommunityStatKeys.BombPlants); + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnBombDefused(EventBombDefused @event, GameEventInfo info) + { + Objective(@event.Userid, CommunityStatKeys.BombDefuses); + + return HookResult.Continue; + } + + [GameEventHandler] + public HookResult OnHostageRescued(EventHostageRescued @event, GameEventInfo info) + { + Objective(@event.Userid, CommunityStatKeys.HostageRescues); + + 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 OnRoundEnd(EventRoundEnd @event, GameEventInfo info) { @@ -572,6 +769,21 @@ public HookResult OnRoundMvp(EventRoundMvp @event, GameEventInfo info) return HookResult.Continue; } + private void Objective(CCSPlayerController? player, string key) + { + try + { + if (player != null && IsHuman(player)) + { + _roster.Objective(VerifiedSteamIdOf(player), key, IsWarmup()); + } + } + catch (Exception error) + { + Logger.LogError(error, "unable to count an objective"); + } + } + // Kept apart from the enforcement that follows it on the tick, which must // run whatever happens to the roster. private void ObserveRoster(List humans) @@ -586,6 +798,29 @@ private void ObserveRoster(List humans) } } + // Some guns share another's class (see CommunityWeapons), and weapon_fire + // may then name the other gun, while player_hurt and player_death name + // the item. Only for those names is the gun in hand read by its item + // definition index; anything unreadable keeps the event's name. + private static string FiredWeapon(CCSPlayerController player, string weapon) + { + if (!CommunityWeapons.SharesAClass(weapon)) + { + return weapon; + } + + CBasePlayerWeapon? active = player.PlayerPawn.Value?.WeaponServices?.ActiveWeapon.Value; + + if (active == null || !active.IsValid) + { + return weapon; + } + + return CommunityWeapons.FirearmByDefinition( + active.AttributeManager.Item.ItemDefinitionIndex + ) ?? weapon; + } + // 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..ce373981 100644 --- a/apps/player-management-sw/src/PlayerManagement.csproj +++ b/apps/player-management-sw/src/PlayerManagement.csproj @@ -27,6 +27,9 @@ + + + diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 69436292..40adaaba 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -483,12 +483,59 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) @event.AssistedFlash ); + // A flash assist is counted apart, by the same rule as a kill. + bool flashAssist = + @event.AssistedFlash + && assister != null + && IsHuman(assister) + && PlayerRoster.CountsAsKill( + assister.PlayerID, + victim.PlayerID, + assister.Controller.TeamNum, + victim.Controller.TeamNum, + teammatesAreEnemies + ); + + bool teamKill = + attacker != null + && IsHuman(attacker) + && PlayerRoster.IsTeamKill( + attacker.PlayerID, + victim.PlayerID, + attacker.Controller.TeamNum, + victim.Controller.TeamNum, + teammatesAreEnemies + ); + + KillFacts facts = new( + Weapon: @event.Weapon, + Time: Core.Engine.GlobalVars.CurrentTime, + KillerSlot: kill ? attacker!.PlayerID : -1, + KillerTeam: kill ? attacker!.Controller.TeamNum : 0, + VictimSlot: victim.PlayerID, + VictimTeam: victim.Controller.TeamNum, + TeammatesAreEnemies: teammatesAreEnemies, + NoScope: @event.NoScope, + ThruSmoke: @event.ThruSmoke, + AttackerBlind: @event.AttackerBlind, + Penetrated: @event.Penetrated, + AttackerInAir: @event.AttackerInAir, + AssistedFlash: flashAssist, + Suicide: PlayerRoster.IsSuicide( + attacker != null && attacker.IsValid ? attacker.PlayerID : null, + victim.PlayerID, + @event.Weapon + ), + TeamKiller: teamKill ? VerifiedSteamIdOf(attacker!) : null + ); + _roster.Died( VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null, IsWarmup(), - assist ? VerifiedSteamIdOf(assister!) : null, - @event.Headshot + assist || flashAssist ? VerifiedSteamIdOf(assister!) : null, + @event.Headshot, + facts ); } catch (Exception error) @@ -506,7 +553,7 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) { IPlayer? victim = @event.UserIdPlayer; - if (victim == null || !IsHuman(victim)) + if (victim == null || !victim.IsValid) { return HookResult.Continue; } @@ -518,28 +565,50 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) ); _health[victim.PlayerID] = @event.ActualHealth; + // A hit on a bot counts, damage to one does not, and only a human + // hurting a human who is no enemy is team damage. Nothing counts + // without an attacker, between two bots, or for a bot hurt by + // anything but a gun, which spares those the lookups below. IPlayer? attacker = @event.AttackerPlayer; - if ( - attacker == null - || !IsHuman(attacker) - || !PlayerRoster.CountsAsKill( - attacker.PlayerID, - victim.PlayerID, - attacker.Controller.TeamNum, - victim.Controller.TeamNum, - TeammatesAreEnemies() - ) - ) + if (attacker == null || !attacker.IsValid) + { + return HookResult.Continue; + } + + bool attackerIsHuman = IsHuman(attacker); + bool victimIsHuman = IsHuman(victim); + + if (!victimIsHuman && (!attackerIsHuman || !CommunityWeapons.IsFirearm(@event.Weapon))) { return HookResult.Continue; } + bool teammatesAreEnemies = TeammatesAreEnemies(); + + bool enemy = PlayerRoster.CountsAsKill( + attacker.PlayerID, + victim.PlayerID, + attacker.Controller.TeamNum, + victim.Controller.TeamNum, + teammatesAreEnemies + ); + _roster.Hurt( - VerifiedSteamIdOf(attacker), - VerifiedSteamIdOf(victim), + attackerIsHuman ? VerifiedSteamIdOf(attacker) : null, + victimIsHuman ? VerifiedSteamIdOf(victim) : null, dealt, - IsWarmup() + IsWarmup(), + new HitFacts( + @event.Weapon, + (int)@event.ActualHitGroup, + Core.Engine.GlobalVars.TickCount, + victim.PlayerID, + victimIsHuman, + enemy, + attackerIsHuman && victimIsHuman && !enemy, + teammatesAreEnemies + ) ); } catch (Exception error) @@ -570,6 +639,134 @@ public HookResult OnPlayerSpawn(EventPlayerSpawn @event) return HookResult.Continue; } + [GameEventHandler(HookMode.Post)] + public HookResult OnWeaponFire(EventWeaponFire @event) + { + try + { + // This runs for every shot and every throw, so the cheap checks + // come before the lookups. + if (!CommunityWeapons.IsFirearm(@event.Weapon)) + { + return HookResult.Continue; + } + + IPlayer? player = @event.UserIdPlayer; + + if (player == null || !IsHuman(player)) + { + return HookResult.Continue; + } + + string? steamId = VerifiedSteamIdOf(player); + + if (steamId == null) + { + return HookResult.Continue; + } + + _roster.Fired(steamId, FiredWeapon(player, @event.Weapon), IsWarmup()); + } + catch (Exception error) + { + _logger.LogError(error, "unable to count a shot"); + } + + return HookResult.Continue; + } + + // Blinding oneself is blinding a teammate, as it is on the match side. + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerBlind(EventPlayerBlind @event) + { + try + { + IPlayer? victim = @event.UserIdPlayer; + IPlayer? attacker = @event.AttackerPlayer; + + if (victim == null || !IsHuman(victim) || attacker == null || !IsHuman(attacker)) + { + return HookResult.Continue; + } + + // A spectator or a dead player can be blinded too, and counts for + // nothing. + int victimTeam = victim.Controller.TeamNum; + + if ( + (victimTeam != PlayerRoster.TeamT && victimTeam != PlayerRoster.TeamCT) + || !victim.IsAlive + ) + { + return HookResult.Continue; + } + + bool teammatesAreEnemies = TeammatesAreEnemies(); + + bool sameTeam = !PlayerRoster.CountsAsKill( + attacker.PlayerID, + victim.PlayerID, + attacker.Controller.TeamNum, + victimTeam, + teammatesAreEnemies + ); + + _roster.Blinded( + VerifiedSteamIdOf(attacker), + VerifiedSteamIdOf(victim), + @event.BlindDuration, + sameTeam, + IsWarmup(), + teammatesAreEnemies + ); + } + catch (Exception error) + { + _logger.LogError(error, "unable to count a flash"); + } + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnBombPlanted(EventBombPlanted @event) + { + Objective(@event.UserIdPlayer, CommunityStatKeys.BombPlants); + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnBombDefused(EventBombDefused @event) + { + Objective(@event.UserIdPlayer, CommunityStatKeys.BombDefuses); + + return HookResult.Continue; + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnHostageRescued(EventHostageRescued @event) + { + Objective(@event.UserIdPlayer, CommunityStatKeys.HostageRescues); + + 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 OnRoundEnd(EventRoundEnd @event) { @@ -624,6 +821,21 @@ public HookResult OnRoundMvp(EventRoundMvp @event) return HookResult.Continue; } + private void Objective(IPlayer? player, string key) + { + try + { + if (player != null && IsHuman(player)) + { + _roster.Objective(VerifiedSteamIdOf(player), key, IsWarmup()); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to count an objective"); + } + } + // Kept apart from the enforcement that follows it on the tick, which must // run whatever happens to the roster. private void ObserveRoster(List humans) @@ -638,6 +850,29 @@ private void ObserveRoster(List humans) } } + // Some guns share another's class (see CommunityWeapons), and weapon_fire + // may then name the other gun, while player_hurt and player_death name + // the item. Only for those names is the gun in hand read by its item + // definition index; anything unreadable keeps the event's name. + private static string FiredWeapon(IPlayer player, string weapon) + { + if (!CommunityWeapons.SharesAClass(weapon)) + { + return weapon; + } + + CBasePlayerWeapon? active = player.PlayerPawn?.WeaponServices?.ActiveWeapon.Value; + + if (active == null || !active.IsValid) + { + return weapon; + } + + return CommunityWeapons.FirearmByDefinition( + active.AttributeManager.Item.ItemDefinitionIndex + ) ?? weapon; + } + // Unreadable is taken as teams as usual, where a team kill earns nothing. private bool TeammatesAreEnemies() { diff --git a/apps/player-management-sw/test/CommunityTradesTests.cs b/apps/player-management-sw/test/CommunityTradesTests.cs new file mode 100644 index 00000000..3baf1057 --- /dev/null +++ b/apps/player-management-sw/test/CommunityTradesTests.cs @@ -0,0 +1,124 @@ +using FiveStack.Utilities; +using Xunit; + +// Slots 1 and 2 are T, slots 3 and 4 are CT. +public class CommunityTradesTests +{ + private const int T = PlayerRoster.TeamT; + private const int CT = PlayerRoster.TeamCT; + + [Theory] + [InlineData(4.9f, 1)] + [InlineData(5.0f, 1)] + [InlineData(5.1f, 0)] + public void AKillTradesATeammatesDeathWithinFiveSeconds(float after, int expected) + { + CommunityTrades trades = new(); + + Assert.Equal(0, trades.Killed(3, CT, 1, T, 10f)); + Assert.Equal(expected, trades.Killed(2, T, 3, CT, 10f + after)); + } + + [Fact] + public void KillingSomeoneWhoKilledTwoTeammatesTradesBoth() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + trades.Killed(3, CT, 2, T, 11f); + + Assert.Equal(2, trades.Killed(5, T, 3, CT, 12f)); + } + + [Fact] + public void ANewRoundClearsTheKills() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + trades.Clear(); + + Assert.Equal(0, trades.Killed(2, T, 3, CT, 11f)); + } + + // Only the killer of a teammate is traded, and only by a teammate of the + // one who died; a team kill of the killer trades nothing, and the killer + // has then died. + [Fact] + public void OnlyTheKillerOfATeammateIsTraded() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + + Assert.Equal(0, trades.Killed(2, T, 4, CT, 11f)); + Assert.Equal(1, trades.Killed(2, T, 3, CT, 12f)); + + CommunityTrades teamKill = new(); + + teamKill.Killed(3, CT, 1, T, 10f); + + Assert.Equal(0, teamKill.Killed(4, CT, 3, CT, 11f)); + Assert.Equal(0, teamKill.Killed(2, T, 3, CT, 12f)); + } + + // A player who respawns and kills their own killer has avenged nobody. + [Fact] + public void AVictimDoesNotTradeTheirOwnDeath() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + + Assert.Equal(0, trades.Killed(1, T, 3, CT, 12f)); + } + + // A trading kill is a kill like any other, and can be traded in turn. + [Fact] + public void ATradeCanBeTraded() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + trades.Killed(2, T, 3, CT, 11f); + + Assert.Equal(1, trades.Killed(4, CT, 2, T, 12f)); + } + + // Where players respawn, a killer who dies and comes back has paid for + // the kills they made before: killing them again is no second trade. + [Fact] + public void ADeathIsTradedOnlyOnce() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + + Assert.Equal(1, trades.Killed(2, T, 3, CT, 11f)); + Assert.Equal(0, trades.Killed(5, T, 3, CT, 13f)); + } + + // A killer who falls to their death and respawns took their pending kills + // with them. + [Fact] + public void AKillerWhoDiesAnyOtherWayIsNoLongerTraded() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + trades.Died(3); + + Assert.Equal(0, trades.Killed(2, T, 3, CT, 12f)); + } + + [Fact] + public void AnotherPlayersDeathLeavesAKillerTraded() + { + CommunityTrades trades = new(); + + trades.Killed(3, CT, 1, T, 10f); + trades.Died(4); + + Assert.Equal(1, trades.Killed(2, T, 3, CT, 12f)); + } +} diff --git a/apps/player-management-sw/test/CommunityWeaponsTests.cs b/apps/player-management-sw/test/CommunityWeaponsTests.cs new file mode 100644 index 00000000..5361ee23 --- /dev/null +++ b/apps/player-management-sw/test/CommunityWeaponsTests.cs @@ -0,0 +1,140 @@ +using FiveStack.Utilities; +using Xunit; + +public class CommunityWeaponsTests +{ + [Theory] + [InlineData("weapon_ak47", "ak47")] + [InlineData("ak47", "ak47")] + [InlineData("AK47", "ak47")] + [InlineData("weapon_knife", "knife")] + [InlineData("knife_t", "knife")] + [InlineData("knife_karambit", "knife")] + [InlineData("weapon_knife_butterfly", "knife")] + [InlineData("bayonet", "knife")] + [InlineData("weapon_bayonet", "knife")] + [InlineData("hegrenade", "hegrenade")] + [InlineData("inferno", "inferno")] + [InlineData("taser", "taser")] + [InlineData(" weapon_m4a1_silencer ", "m4a1_silencer")] + [InlineData("weapon_m4a1_silencer_off", "m4a1_silencer")] + [InlineData("usp_silencer_off", "usp_silencer")] + [InlineData("weapon_usp_silencer", "usp_silencer")] + [InlineData("", "")] + [InlineData(null, "")] + public void WeaponsAreNamedAsTheMatchStatsNameThem(string? raw, string expected) + { + Assert.Equal(expected, CommunityWeapons.Normalize(raw)); + } + + [Theory] + [InlineData("weapon_ak47", true)] + [InlineData("deagle", true)] + [InlineData("nova", true)] + [InlineData("m4a1_silencer", true)] + [InlineData("knife", false)] + [InlineData("knife_t", false)] + [InlineData("bayonet", false)] + [InlineData("weapon_hegrenade", false)] + [InlineData("flashbang", false)] + [InlineData("smokegrenade", false)] + [InlineData("molotov", false)] + [InlineData("incgrenade", false)] + [InlineData("decoy", false)] + [InlineData("inferno", false)] + [InlineData("taser", false)] + [InlineData("weapon_c4", false)] + [InlineData("planted_c4", false)] + [InlineData("healthshot", false)] + [InlineData("world", false)] + [InlineData("worldent", false)] + [InlineData("fists", false)] + [InlineData("weapon_fists", false)] + [InlineData("snowball", false)] + [InlineData("bumpmine", false)] + [InlineData("breachcharge", false)] + [InlineData("shield", false)] + [InlineData("tablet", false)] + [InlineData("firebomb", false)] + [InlineData("trigger_hurt", false)] + [InlineData("tagrenade", false)] + [InlineData("hammer", false)] + [InlineData("gun0", false)] + [InlineData("usp_silencer", true)] + [InlineData("usp_silencer_off", true)] + [InlineData("m4a1_silencer_off", true)] + [InlineData("hkp2000", true)] + [InlineData("cz75a", true)] + [InlineData("revolver", true)] + [InlineData("mp5sd", true)] + [InlineData("m4a1", true)] + [InlineData("", false)] + [InlineData(null, false)] + public void OnlyGunsAreFirearms(string? weapon, bool expected) + { + Assert.Equal(expected, CommunityWeapons.IsFirearm(weapon)); + } + + [Theory] + [InlineData("knife", CommunityStatKeys.KnifeKills)] + [InlineData("knife_t", CommunityStatKeys.KnifeKills)] + [InlineData("bayonet", CommunityStatKeys.KnifeKills)] + [InlineData("taser", CommunityStatKeys.ZeusKills)] + [InlineData("hegrenade", CommunityStatKeys.HeKills)] + [InlineData("inferno", CommunityStatKeys.MolotovKills)] + [InlineData("molotov", CommunityStatKeys.MolotovKills)] + [InlineData("ak47", null)] + [InlineData("flashbang", null)] + [InlineData("", null)] + public void KillTypesGoByTheWeapon(string weapon, string? expected) + { + Assert.Equal(expected, CommunityWeapons.KillType(weapon)); + } + + [Theory] + [InlineData("hegrenade", CommunityStatKeys.HeDamage)] + [InlineData("inferno", CommunityStatKeys.MolotovDamage)] + [InlineData("molotov", CommunityStatKeys.MolotovDamage)] + [InlineData("ak47", null)] + [InlineData("knife", null)] + public void UtilityDamageGoesByTheWeapon(string weapon, string? expected) + { + Assert.Equal(expected, CommunityWeapons.DamageType(weapon)); + } + + // CS2's item definition indexes, which tell apart the guns that share a + // class with another. + [Theory] + [InlineData(60, "m4a1_silencer")] + [InlineData(16, "m4a1")] + [InlineData(61, "usp_silencer")] + [InlineData(32, "hkp2000")] + [InlineData(63, "cz75a")] + [InlineData(36, "p250")] + [InlineData(64, "revolver")] + [InlineData(1, "deagle")] + [InlineData(23, "mp5sd")] + [InlineData(33, "mp7")] + [InlineData(7, "ak47")] + [InlineData(42, null)] + [InlineData(31, null)] + [InlineData(0, null)] + public void AnItemDefinitionNamesItsGun(int index, string? expected) + { + Assert.Equal(expected, CommunityWeapons.FirearmByDefinition(index)); + } + + [Theory] + [InlineData("weapon_m4a1", true)] + [InlineData("hkp2000", true)] + [InlineData("p250", true)] + [InlineData("deagle", true)] + [InlineData("mp7", true)] + [InlineData("m4a1_silencer", false)] + [InlineData("ak47", false)] + [InlineData("knife", false)] + public void SomeGunsShareTheirClassWithAnother(string weapon, bool expected) + { + Assert.Equal(expected, CommunityWeapons.SharesAClass(weapon)); + } +} diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj index 0849a281..7bdf4e78 100644 --- a/apps/player-management-sw/test/FiveStack.Tests.csproj +++ b/apps/player-management-sw/test/FiveStack.Tests.csproj @@ -20,6 +20,9 @@ + + + diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index d5bc14bc..a6c89b32 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1503,4 +1503,956 @@ public void CountsEveryConnectedHumanAndNothingElse() Assert.False(PlayerRoster.IsConnectedHuman(false, true, true, 76561198000000001)); Assert.False(PlayerRoster.IsConnectedHuman(false, true, false, 0)); } + + private const int T = PlayerRoster.TeamT; + private const int CT = PlayerRoster.TeamCT; + + private static Dictionary StatsOf(RosterReport report, string steamId) + { + return report.Players!.Single(player => player.steam_id == steamId).stats ?? new(); + } + + private static WeaponTotals? WeaponOf(RosterReport report, string steamId, string weapon) + { + return report + .Players!.Single(player => player.steam_id == steamId) + .weapons?.GetValueOrDefault(weapon); + } + + // Player 1 (slot 1, T) kills player 2 (slot 2, CT) with a counted kill. + private static KillFacts Kill( + string weapon = "ak47", + float time = 10f, + int killer = 1, + int killerTeam = T, + int victim = 2, + int victimTeam = CT + ) + { + return new KillFacts( + Weapon: weapon, + Time: time, + KillerSlot: killer, + KillerTeam: killerTeam, + VictimSlot: victim, + VictimTeam: victimTeam + ); + } + + // Player 1 hits player 2 (slot 2), an enemy, in the chest. + private static HitFacts Hit( + string weapon = "ak47", + int hitGroup = 2, + int tick = 100, + int victim = 2, + bool human = true + ) + { + return new HitFacts(weapon, hitGroup, tick, victim, human, Enemy: true); + } + + [Fact] + public void EachKillTypeCountsOnACountedKill() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died("2", "1", warmup: false, facts: Kill("knife_t")); + roster.Died("2", "1", warmup: false, facts: Kill("taser")); + roster.Died("2", "1", warmup: false, facts: Kill("hegrenade")); + roster.Died("2", "1", warmup: false, facts: Kill("inferno")); + roster.Died("2", "1", warmup: false, facts: Kill("awp") with { NoScope = true }); + roster.Died("2", "1", warmup: false, facts: Kill() with { ThruSmoke = true }); + roster.Died("2", "1", warmup: false, facts: Kill() with { Penetrated = 1 }); + roster.Died("2", "1", warmup: false, facts: Kill() with { AttackerBlind = true }); + roster.Died("2", "1", warmup: false, facts: Kill() with { AttackerInAir = true }); + + Dictionary stats = StatsOf(roster.Report(At(1)), "1"); + + Assert.Equal(1, stats[CommunityStatKeys.KnifeKills]); + Assert.Equal(1, stats[CommunityStatKeys.ZeusKills]); + Assert.Equal(1, stats[CommunityStatKeys.HeKills]); + Assert.Equal(1, stats[CommunityStatKeys.MolotovKills]); + Assert.Equal(1, stats[CommunityStatKeys.NoScopeKills]); + Assert.Equal(1, stats[CommunityStatKeys.ThruSmokeKills]); + Assert.Equal(1, stats[CommunityStatKeys.ThruWallKills]); + Assert.Equal(1, stats[CommunityStatKeys.BlindedKills]); + Assert.Equal(1, stats[CommunityStatKeys.InAirKills]); + } + + // The hooks pass no killer for a kill that does not count: a bot's death, + // a teammate's, or a kill by an unverified player. + [Fact] + public void AKillThatDoesNotCountHasNoKillType() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died( + "2", + null, + warmup: false, + headshot: true, + facts: Kill("knife") with + { + KillerSlot = -1, + NoScope = true, + } + ); + + RosterReport report = roster.Report(At(1)); + + Assert.Null(report.Players!.Single(player => player.steam_id == "1").stats); + Assert.Null(report.Players!.Single(player => player.steam_id == "1").weapons); + } + + [Fact] + public void ATeamKillAndASuicideAreCountedAndAreDeaths() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died( + "2", + null, + warmup: false, + facts: Kill() with + { + KillerSlot = -1, + TeamKiller = "1", + } + ); + roster.Died("1", null, warmup: false, facts: new KillFacts(Suicide: true)); + roster.Died( + "2", + null, + warmup: false, + facts: Kill() with + { + KillerSlot = -1, + TeamKiller = "bot", + } + ); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(1, StatsOf(report, "1")[CommunityStatKeys.TeamKills]); + Assert.Equal(1, StatsOf(report, "1")[CommunityStatKeys.Suicides]); + Assert.Equal(new Counters(0, 1, 0, 0, 0, 0, 0, 0), CountersOf(report, "1")); + Assert.Equal(new Counters(0, 2, 0, 0, 0, 0, 0, 0), CountersOf(report, "2")); + Assert.False(StatsOf(report, "2").ContainsKey(CommunityStatKeys.TeamKills)); + } + + // Killing one's own teammate is a team kill only while teammates are not + // enemies, and killing oneself never is. + [Theory] + [InlineData(1, 2, 2, 2, false, true)] + [InlineData(1, 2, 2, 2, true, false)] + [InlineData(1, 2, 2, 3, false, false)] + [InlineData(1, 1, 2, 2, false, false)] + public void ATeamKillIsAnotherPlayerOnOnesOwnSide( + int attacker, + int victim, + int attackerTeam, + int victimTeam, + bool teammatesAreEnemies, + bool expected + ) + { + Assert.Equal( + expected, + PlayerRoster.IsTeamKill(attacker, victim, attackerTeam, victimTeam, teammatesAreEnemies) + ); + } + + // The hooks pass an assister that is an enemy of the victim, flash or not, + // and the facts say which it was. + [Fact] + public void AFlashAssistIsCountedApartFromAssists() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died( + "2", + "1", + warmup: false, + assister: "3", + facts: Kill() with + { + AssistedFlash = true, + } + ); + roster.Died("2", "1", warmup: false, assister: "3", facts: Kill()); + roster.Died( + "2", + "1", + warmup: false, + assister: "1", + facts: Kill() with + { + AssistedFlash = true, + } + ); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(1, CountersOf(report, "3").Assists); + Assert.Equal(1, StatsOf(report, "3")[CommunityStatKeys.FlashAssists]); + Assert.False(StatsOf(report, "1").ContainsKey(CommunityStatKeys.FlashAssists)); + } + + [Fact] + public void KillsAndHeadshotsAreCountedPerWeapon() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died("2", "1", warmup: false, headshot: true, facts: Kill("ak47")); + roster.Died("2", "1", warmup: false, facts: Kill("ak47")); + roster.Died("2", "1", warmup: false, facts: Kill("knife_karambit")); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(2, WeaponOf(report, "1", "ak47")!.kills); + Assert.Equal(1, WeaponOf(report, "1", "ak47")!.headshots); + Assert.Equal(1, WeaponOf(report, "1", "knife")!.kills); + Assert.Equal(0, WeaponOf(report, "1", "knife")!.headshots); + } + + [Fact] + public void TheBestStreakIsCountedKillsBetweenDeaths() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died("2", "1", warmup: false); + roster.Died("2", "1", warmup: false); + roster.RoundEnded(T, Teams(("1", T), ("2", CT)), warmup: false); + roster.RoundStarted(); + roster.Died("2", "1", warmup: false); + roster.Died("2", "bot", warmup: false); + roster.Died("1", "bot", warmup: false); + roster.Died("2", "1", warmup: false); + roster.Died("1", null, warmup: false, facts: new KillFacts(Suicide: true)); + roster.Died("2", "1", warmup: false); + roster.Died("2", "1", warmup: false); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(3, StatsOf(report, "1")[CommunityStatKeys.BestStreak]); + Assert.False(StatsOf(report, "2").ContainsKey(CommunityStatKeys.BestStreak)); + } + + [Fact] + public void ANewMapStartsTheStreakOver() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + roster.Died("2", "1", warmup: false); + roster.Died("2", "1", warmup: false); + + roster.MapEnded(At(1)); + RosterReport final = roster.Report(At(1)); + roster.Delivered(final, true); + roster.MapStarted(At(2), "de_inferno"); + roster.Observe([Human("1"), Human("2")], At(3)); + roster.Died("2", "1", warmup: false); + + Assert.Equal(2, StatsOf(final, "1")[CommunityStatKeys.BestStreak]); + Assert.Equal(1, StatsOf(roster.Report(At(4)), "1")[CommunityStatKeys.BestStreak]); + } + + // Slots 1 and 3 are T with players 1 and 3, slot 2 is CT with player 2. + [Fact] + public void KillingATeammatesKillerWithinFiveSecondsIsATrade() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died( + "3", + "2", + warmup: false, + facts: Kill(time: 10f, killer: 2, killerTeam: CT, victim: 3, victimTeam: T) + ); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 14f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) + ); + + roster.Died( + "3", + "2", + warmup: false, + facts: Kill(time: 20f, killer: 2, killerTeam: CT, victim: 3, victimTeam: T) + ); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 26f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) + ); + + roster.Died( + "3", + "2", + warmup: false, + facts: Kill(time: 30f, killer: 2, killerTeam: CT, victim: 3, victimTeam: T) + ); + roster.RoundStarted(); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 31f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) + ); + + Assert.Equal(1, StatsOf(roster.Report(At(1)), "1")[CommunityStatKeys.TradeKills]); + } + + // Where teammates are enemies there is nobody to avenge. + [Fact] + public void ThereAreNoTradesWhereTeammatesAreEnemies() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died( + "3", + "2", + warmup: false, + facts: Kill(time: 10f, killer: 2, killerTeam: CT, victim: 3, victimTeam: T) with + { + TeammatesAreEnemies = true, + } + ); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 11f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) with + { + TeammatesAreEnemies = true, + } + ); + + Assert.False(StatsOf(roster.Report(At(1)), "1").ContainsKey(CommunityStatKeys.TradeKills)); + } + + [Fact] + public void AHitOnAnEnemyIsCountedByHitgroupAndWeapon() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Hurt("1", "2", 27, warmup: false, Hit(hitGroup: 2, tick: 100)); + roster.Hurt("1", "2", 73, warmup: false, Hit(hitGroup: 1, tick: 101)); + roster.Hurt("1", "2", 20, warmup: false, Hit(hitGroup: 8, tick: 102)); + roster.Hurt("1", "2", 5, warmup: false, Hit(hitGroup: 0, tick: 103)); + roster.Hurt("1", "2", 5, warmup: false, Hit(hitGroup: 10, tick: 104)); + + RosterReport report = roster.Report(At(1)); + Dictionary stats = StatsOf(report, "1"); + + Assert.Equal(5, stats[CommunityStatKeys.Hits]); + Assert.Equal(1, stats[CommunityStatKeys.HitsChest]); + Assert.Equal(1, stats[CommunityStatKeys.HitsHead]); + Assert.Equal(1, stats[CommunityStatKeys.HitsNeck]); + Assert.Equal(5, WeaponOf(report, "1", "ak47")!.hits); + Assert.Equal(130, WeaponOf(report, "1", "ak47")!.damage); + Assert.Equal(130, CountersOf(report, "1").Damage); + Assert.Equal(130, StatsOf(report, "2")[CommunityStatKeys.DamageTaken]); + } + + // A shotgun's pellets each report a hit, all in one tick. + [Fact] + public void PelletsInOneTickAreOneHitOnEachVictim() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 100, victim: 2)); + roster.Hurt("1", "3", 20, warmup: false, Hit("nova", tick: 100, victim: 3)); + roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 100, victim: 2)); + roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 164, victim: 2)); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(3, StatsOf(report, "1")[CommunityStatKeys.Hits]); + Assert.Equal(3, WeaponOf(report, "1", "nova")!.hits); + Assert.Equal(80, WeaponOf(report, "1", "nova")!.damage); + } + + // Shots and hits on bots count; damage to them does not. + [Fact] + public void AHitOnABotIsAHitAndNoDamage() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Hurt("1", null, 40, warmup: false, Hit(hitGroup: 1, victim: 5, human: false)); + roster.Hurt("1", null, 40, warmup: false, Hit("hegrenade", victim: 5, human: false)); + roster.Fired("1", "weapon_ak47", warmup: false); + + RosterReport report = roster.Report(At(1)); + Dictionary stats = StatsOf(report, "1"); + + Assert.Equal(1, stats[CommunityStatKeys.Shots]); + Assert.Equal(1, stats[CommunityStatKeys.Hits]); + Assert.Equal(1, stats[CommunityStatKeys.HitsHead]); + Assert.False(stats.ContainsKey(CommunityStatKeys.HeDamage)); + Assert.Equal(0, CountersOf(report, "1").Damage); + Assert.Equal(1, WeaponOf(report, "1", "ak47")!.shots); + Assert.Equal(1, WeaponOf(report, "1", "ak47")!.hits); + Assert.Equal(0, WeaponOf(report, "1", "ak47")!.damage); + Assert.Null(WeaponOf(report, "1", "hegrenade")); + } + + // Only bullets hit: a knife, a grenade or a fire does damage but is no hit. + [Fact] + public void OnlyAFirearmHits() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Hurt("1", "2", 40, warmup: false, Hit("knife", tick: 100)); + roster.Hurt("1", "2", 30, warmup: false, Hit("hegrenade", tick: 101)); + roster.Hurt("1", "2", 8, warmup: false, Hit("inferno", tick: 102)); + roster.Hurt("1", "2", 7, warmup: false, Hit("molotov", tick: 103)); + roster.Fired("1", "weapon_knife", warmup: false); + roster.Fired("1", "weapon_hegrenade", warmup: false); + + RosterReport report = roster.Report(At(1)); + Dictionary stats = StatsOf(report, "1"); + + Assert.False(stats.ContainsKey(CommunityStatKeys.Hits)); + Assert.False(stats.ContainsKey(CommunityStatKeys.Shots)); + Assert.Equal(30, stats[CommunityStatKeys.HeDamage]); + Assert.Equal(15, stats[CommunityStatKeys.MolotovDamage]); + Assert.Equal(85, CountersOf(report, "1").Damage); + Assert.Equal(40, WeaponOf(report, "1", "knife")!.damage); + Assert.Equal(30, WeaponOf(report, "1", "hegrenade")!.damage); + } + + // A teammate's or one's own grenade is team damage, as on the match side; + // it is no damage dealt, no damage taken and no hit. + [Fact] + public void TeamAndSelfDamageCountAsTeamDamageOnly() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + HitFacts team = new("ak47", 2, 100, 2, true, Enemy: false, Team: true); + HitFacts self = new("hegrenade", 0, 101, 1, true, Enemy: false, Team: true); + + roster.Hurt("1", "2", 25, warmup: false, team); + roster.Hurt("1", "1", 40, warmup: false, self); + + RosterReport report = roster.Report(At(1)); + Dictionary stats = StatsOf(report, "1"); + + Assert.Equal(65, stats[CommunityStatKeys.TeamDamage]); + Assert.False(stats.ContainsKey(CommunityStatKeys.Hits)); + Assert.False(stats.ContainsKey(CommunityStatKeys.HeDamage)); + Assert.False(stats.ContainsKey(CommunityStatKeys.DamageTaken)); + Assert.False(StatsOf(report, "2").ContainsKey(CommunityStatKeys.DamageTaken)); + Assert.Equal(0, CountersOf(report, "1").Damage); + Assert.Null(report.Players!.Single(player => player.steam_id == "1").weapons); + } + + // A bot or an unverified enemy still hurts; the world and oneself do not + // count as damage taken. + [Fact] + public void DamageTakenIsWhatAnyEnemyDealt() + { + PlayerRoster roster = new(); + roster.Observe([Human("2")], Start); + + roster.Hurt( + null, + "2", + 30, + warmup: false, + new HitFacts("ak47", 2, 100, 2, true, Enemy: true) + ); + roster.Hurt(null, "2", 12, warmup: false, new HitFacts("", 0, 101, 2, true, Enemy: false)); + roster.Hurt( + "2", + "2", + 40, + warmup: false, + new HitFacts("hegrenade", 0, 102, 2, true, Enemy: false, Team: true) + ); + + Assert.Equal(30, StatsOf(roster.Report(At(1)), "2")[CommunityStatKeys.DamageTaken]); + } + + // A hit on a teammate is a hit on nobody the player was aiming for, and a + // bot teammate's health is no team damage. + [Fact] + public void AHitOnATeammateOrABotTeammateIsNoHit() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Hurt( + "1", + null, + 25, + warmup: false, + new HitFacts("ak47", 2, 100, 5, false, Enemy: false) + ); + + Assert.Null(roster.Report(At(1)).Players!.Single().stats); + } + + [Theory] + [InlineData(2.5f, false, 1, 0, 2500)] + [InlineData(1.01f, false, 1, 0, 1010)] + [InlineData(1.0f, false, 0, 0, 0)] + [InlineData(0.4f, false, 0, 0, 0)] + [InlineData(2.5f, true, 0, 1, 0)] + [InlineData(1.0f, true, 0, 0, 0)] + public void AFlashLongerThanASecondIsCountedByTeam( + float seconds, + bool sameTeam, + int enemies, + int team, + int durationMs + ) + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Blinded("1", "2", seconds, sameTeam, warmup: false); + + Dictionary stats = StatsOf(roster.Report(At(1)), "1"); + + Assert.Equal(enemies, stats.GetValueOrDefault(CommunityStatKeys.EnemiesFlashed)); + Assert.Equal(team, stats.GetValueOrDefault(CommunityStatKeys.TeamFlashed)); + Assert.Equal(durationMs, stats.GetValueOrDefault(CommunityStatKeys.FlashDurationMs)); + } + + // Flashing oneself is a team flash, as on the match side. + [Fact] + public void FlashingOneselfIsATeamFlash() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Blinded("1", "1", 3f, sameTeam: true, warmup: false); + roster.Blinded("bot", "1", 3f, sameTeam: false, warmup: false); + roster.Blinded(null, "1", 3f, sameTeam: false, warmup: false); + + Dictionary stats = StatsOf(roster.Report(At(1)), "1"); + + Assert.Equal(1, stats[CommunityStatKeys.TeamFlashed]); + Assert.False(stats.ContainsKey(CommunityStatKeys.EnemiesFlashed)); + } + + [Fact] + public void ShotsAreFirearmShotsByRosterPlayers() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Fired("1", "weapon_ak47", warmup: false); + roster.Fired("1", "weapon_ak47", warmup: false); + roster.Fired("1", "weapon_deagle", warmup: false); + roster.Fired("1", "weapon_taser", warmup: false); + roster.Fired("bot", "weapon_ak47", warmup: false); + roster.Fired(null, "weapon_ak47", warmup: false); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(3, StatsOf(report, "1")[CommunityStatKeys.Shots]); + Assert.Equal(2, WeaponOf(report, "1", "ak47")!.shots); + Assert.Equal(1, WeaponOf(report, "1", "deagle")!.shots); + Assert.Null(WeaponOf(report, "1", "taser")); + } + + // A silenced gun is one gun whether its silencer is on or off. + [Fact] + public void ASilencedGunKeepsOneKeyWithItsSilencerOff() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Fired("1", "weapon_m4a1_silencer", warmup: false); + roster.Died("2", "1", warmup: false, facts: Kill("weapon_m4a1_silencer_off")); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(1, WeaponOf(report, "1", "m4a1_silencer")!.shots); + Assert.Equal(1, WeaponOf(report, "1", "m4a1_silencer")!.kills); + Assert.Null(WeaponOf(report, "1", "m4a1_silencer_off")); + } + + [Fact] + public void ObjectivesGoToRosterPlayersOnly() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Objective("1", CommunityStatKeys.BombPlants, warmup: false); + roster.Objective("1", CommunityStatKeys.BombDefuses, warmup: false); + roster.Objective("1", CommunityStatKeys.HostageRescues, warmup: false); + roster.Objective("1", CommunityStatKeys.HostageRescues, warmup: false); + roster.Objective("bot", CommunityStatKeys.BombPlants, warmup: false); + roster.Objective(null, CommunityStatKeys.BombPlants, warmup: false); + + Dictionary stats = StatsOf(roster.Report(At(1)), "1"); + + Assert.Equal(1, stats[CommunityStatKeys.BombPlants]); + Assert.Equal(1, stats[CommunityStatKeys.BombDefuses]); + Assert.Equal(2, stats[CommunityStatKeys.HostageRescues]); + } + + [Fact] + public void NoDetailedStatIsCountedInWarmup() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died( + "2", + "1", + warmup: true, + assister: "3", + facts: Kill("knife") with + { + AssistedFlash = true, + } + ); + roster.Died( + "2", + null, + warmup: true, + facts: Kill() with + { + KillerSlot = -1, + TeamKiller = "1", + } + ); + roster.Died("1", null, warmup: true, facts: new KillFacts(Suicide: true)); + roster.Hurt("1", "2", 50, warmup: true, Hit()); + roster.Hurt( + "1", + "1", + 50, + warmup: true, + new HitFacts("hegrenade", 0, 1, 1, true, Enemy: false, Team: true) + ); + roster.Fired("1", "weapon_ak47", warmup: true); + roster.Blinded("1", "2", 3f, sameTeam: false, warmup: true); + roster.Objective("1", CommunityStatKeys.BombPlants, warmup: true); + + RosterReport report = roster.Report(At(1)); + + Assert.All(report.Players!, player => Assert.Null(player.stats)); + Assert.All(report.Players!, player => Assert.Null(player.weapons)); + } + + // The report is serialized once the lock is released, while the game + // thread goes on counting. + [Fact] + public void TheReportHoldsCopiesOfTheCounters() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + roster.Died("2", "1", warmup: false, facts: Kill("knife")); + roster.Fired("1", "weapon_ak47", warmup: false); + + RosterReport first = roster.Report(At(1)); + roster.Died("2", "1", warmup: false, facts: Kill("knife")); + roster.Fired("1", "weapon_ak47", warmup: false); + first.Players!.Single(player => player.steam_id == "1").stats![CommunityStatKeys.Shots] = + 99; + RosterReport second = roster.Report(At(2)); + + Assert.Equal(1, StatsOf(first, "1")[CommunityStatKeys.KnifeKills]); + Assert.Equal(1, WeaponOf(first, "1", "ak47")!.shots); + Assert.Equal(2, StatsOf(second, "1")[CommunityStatKeys.KnifeKills]); + Assert.Equal(2, StatsOf(second, "1")[CommunityStatKeys.Shots]); + Assert.Equal(2, WeaponOf(second, "1", "ak47")!.shots); + } + + [Fact] + public void ANewConnectionStartsWithNoStatsAndADepartureKeepsItsOwn() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + roster.Died("2", "1", warmup: false, facts: Kill("knife")); + roster.Fired("1", "weapon_ak47", warmup: false); + + roster.Observe([Human("2")], At(1)); + roster.Observe([Human("1"), Human("2")], At(2)); + roster.Fired("1", "weapon_ak47", warmup: false); + + RosterReport report = roster.Report(At(3)); + DepartedPlayer departed = Assert.Single(report.Departed); + + Assert.Equal( + new Dictionary + { + [CommunityStatKeys.Shots] = 1, + [CommunityStatKeys.KnifeKills] = 1, + [CommunityStatKeys.BestStreak] = 1, + }, + departed.stats + ); + Assert.Equal(1, departed.weapons!["knife"].kills); + Assert.Equal(1, departed.weapons!["ak47"].shots); + Assert.Equal( + new Dictionary { [CommunityStatKeys.Shots] = 1 }, + StatsOf(report, "1") + ); + Assert.Equal( + ["ak47"], + report.Players!.Single(player => player.steam_id == "1").weapons!.Keys + ); + } + + [Fact] + public void AConnectionKeepsAtMostFortyEightWeapons() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + for (int weapon = 0; weapon < 60; weapon++) + { + roster.Died("2", "1", warmup: false, facts: Kill($"weapon_gun{weapon}")); + } + + RosterReport report = roster.Report(At(1)); + + Assert.Equal( + PlayerRoster.MaxWeapons, + report.Players!.Single(player => player.steam_id == "1").weapons!.Count + ); + Assert.Equal(60, CountersOf(report, "1").Kills); + } + + [Fact] + public void TheDetailedStatsSerializeWithoutZerosAndOnlyWhenThereAreAny() + { + RosterPlayer plain = new() + { + steam_id = "76561198000000001", + conn = "c1", + name = "nyx", + kills = 2, + }; + RosterPlayer detailed = new() + { + steam_id = "76561198000000001", + conn = "c1", + name = "nyx", + kills = 2, + stats = new() { ["shots"] = 412, ["best_streak"] = 5 }, + weapons = new() + { + ["ak47"] = new WeaponTotals + { + kills = 9, + shots = 300, + hits = 95, + }, + }, + }; + DepartedPlayer departed = new() + { + steam_id = "76561198000000002", + conn = "c2", + stats = new() { ["hits"] = 3 }, + }; + + string plainJson = JsonSerializer.Serialize(plain, SanctionsClient.Json); + + Assert.DoesNotContain("stats", plainJson); + Assert.DoesNotContain("weapons", plainJson); + Assert.EndsWith("\"rounds_won\":0,\"mvps\":0}", plainJson); + Assert.EndsWith( + "\"mvps\":0,\"stats\":{\"shots\":412,\"best_streak\":5},\"weapons\":{\"ak47\":{\"kills\":9,\"shots\":300,\"hits\":95}}}", + JsonSerializer.Serialize(detailed, SanctionsClient.Json) + ); + Assert.EndsWith( + "\"mvps\":0,\"stats\":{\"hits\":3}}", + JsonSerializer.Serialize(departed, SanctionsClient.Json) + ); + } + + // Zeros are left out of what the roster reports, so a stat the player has + // not earned is not sent at all. + [Fact] + public void TheRosterLeavesOutStatsThatAreZero() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died("2", "1", warmup: false, facts: Kill()); + + RosterReport report = roster.Report(At(1)); + RosterPlayer player = report.Players!.Single(player => player.steam_id == "1"); + + Assert.Equal( + new Dictionary { [CommunityStatKeys.BestStreak] = 1 }, + player.stats + ); + Assert.Equal(1, player.weapons!["ak47"].kills); + Assert.Null(report.Players!.Single(player => player.steam_id == "2").stats); + Assert.Null(report.Players!.Single(player => player.steam_id == "2").weapons); + } + + // Where players respawn, killing the same killer again within the window + // is no second trade of the same teammate. + [Fact] + public void ARespawnedKillerIsTradedOnlyOnce() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died( + "3", + "2", + warmup: false, + facts: Kill(time: 10f, killer: 2, killerTeam: CT, victim: 3, victimTeam: T) + ); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 11f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) + ); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 13f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) + ); + + Assert.Equal(1, StatsOf(roster.Report(At(1)), "1")[CommunityStatKeys.TradeKills]); + } + + // A killer who dies to the world and respawns is no longer the killer of + // the teammate they killed before. + [Fact] + public void AKillerWhoDiedToTheWorldIsNotTraded() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2"), Human("3")], Start); + + roster.Died( + "3", + "2", + warmup: false, + facts: Kill(time: 10f, killer: 2, killerTeam: CT, victim: 3, victimTeam: T) + ); + roster.Died( + "2", + null, + warmup: false, + facts: new KillFacts( + Weapon: "world", + Time: 11f, + VictimSlot: 2, + VictimTeam: CT, + Suicide: true + ) + ); + roster.Died( + "2", + "1", + warmup: false, + facts: Kill(time: 12f, killer: 1, killerTeam: T, victim: 2, victimTeam: CT) + ); + + Assert.False(StatsOf(roster.Report(At(1)), "1").ContainsKey(CommunityStatKeys.TradeKills)); + } + + // Where teammates are enemies there is no team, so hurting or flashing + // oneself is no team damage or team flash either. + [Fact] + public void ThereAreNoTeamStatsWhereTeammatesAreEnemies() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Hurt( + "1", + "1", + 40, + warmup: false, + new HitFacts( + "hegrenade", + 0, + 100, + 1, + true, + Enemy: false, + Team: true, + TeammatesAreEnemies: true + ) + ); + roster.Blinded("1", "1", 3f, sameTeam: true, warmup: false, teammatesAreEnemies: true); + + Assert.Null(roster.Report(At(1)).Players!.Single().stats); + } + + [Fact] + public void AFlashOnAnotherPlayerWhereTeammatesAreEnemiesIsAnEnemyFlash() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Blinded("1", "2", 3f, sameTeam: false, warmup: false, teammatesAreEnemies: true); + + Assert.Equal(1, StatsOf(roster.Report(At(1)), "1")[CommunityStatKeys.EnemiesFlashed]); + } + + // The bomb is no attacker, but dying to it is no suicide. + [Theory] + [InlineData(2, 2, "hegrenade", true)] + [InlineData(null, 2, "world", true)] + [InlineData(null, 2, "trigger_hurt", true)] + [InlineData(null, 2, "", true)] + [InlineData(null, 2, "planted_c4", false)] + [InlineData(null, 2, "weapon_planted_c4", false)] + [InlineData(1, 2, "ak47", false)] + [InlineData(1, 2, "planted_c4", false)] + public void ASuicideIsADeathByOnesOwnHandOrTheWorldsButNotTheBombs( + int? attacker, + int victim, + string weapon, + bool expected + ) + { + Assert.Equal(expected, PlayerRoster.IsSuicide(attacker, victim, weapon)); + } + + [Fact] + public void ADeathToTheBombIsADeathButNoSuicide() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Died( + "1", + null, + warmup: false, + facts: new KillFacts( + Weapon: "planted_c4", + VictimSlot: 1, + VictimTeam: CT, + Suicide: PlayerRoster.IsSuicide(null, 1, "planted_c4") + ) + ); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(1, CountersOf(report, "1").Deaths); + Assert.Null(report.Players!.Single().stats); + } } diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs index 244f6e72..1c529570 100644 --- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs +++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace FiveStack.Entities.PlayerManagement; public class PlayerSanction @@ -52,6 +54,14 @@ public class RosterPlayer public int rounds_played { get; set; } public int rounds_won { get; set; } public int mvps { get; set; } + + // Ignored by panels that predate the detailed community stats. Both leave + // out what is zero and are left out themselves when nothing is left. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? stats { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? weapons { get; set; } } public class DepartedPlayer @@ -68,6 +78,33 @@ public class DepartedPlayer public int rounds_played { get; set; } public int rounds_won { get; set; } public int mvps { get; set; } + + // Ignored by panels that predate the detailed community stats. Both leave + // out what is zero and are left out themselves when nothing is left. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? stats { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? weapons { get; set; } +} + +// One weapon's totals for a connection, by the name the match stats give it. +public class WeaponTotals +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int kills { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int headshots { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int shots { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int hits { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int damage { get; set; } } public class PlayerSanctionsResponse diff --git a/shared/dotnet/FiveStack.Utilities/CommunityStatKeys.cs b/shared/dotnet/FiveStack.Utilities/CommunityStatKeys.cs new file mode 100644 index 00000000..8f597f1d --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/CommunityStatKeys.cs @@ -0,0 +1,98 @@ +namespace FiveStack.Utilities; + +// The detailed community stats by the names the panel stores them under, which +// are the match stats' names wherever the match side has the same stat. +public static class CommunityStatKeys +{ + public const string Shots = "shots"; + public const string Hits = "hits"; + public const string HitsHead = "hits_head"; + public const string HitsNeck = "hits_neck"; + public const string HitsChest = "hits_chest"; + public const string HitsStomach = "hits_stomach"; + public const string HitsLeftArm = "hits_left_arm"; + public const string HitsRightArm = "hits_right_arm"; + public const string HitsLeftLeg = "hits_left_leg"; + public const string HitsRightLeg = "hits_right_leg"; + public const string DamageTaken = "damage_taken"; + public const string TeamDamage = "team_damage"; + public const string HeDamage = "he_damage"; + public const string MolotovDamage = "molotov_damage"; + public const string FlashAssists = "flash_assists"; + public const string EnemiesFlashed = "enemies_flashed"; + public const string TeamFlashed = "team_flashed"; + public const string FlashDurationMs = "flash_duration_ms"; + public const string KnifeKills = "knife_kills"; + public const string ZeusKills = "zeus_kills"; + public const string HeKills = "he_kills"; + public const string MolotovKills = "molotov_kills"; + public const string NoScopeKills = "no_scope_kills"; + public const string ThruSmokeKills = "thru_smoke_kills"; + public const string ThruWallKills = "thru_wall_kills"; + public const string BlindedKills = "blinded_kills"; + public const string InAirKills = "in_air_kills"; + public const string TeamKills = "team_kills"; + public const string Suicides = "suicides"; + public const string TradeKills = "trade_kills"; + public const string BombPlants = "bomb_plants"; + public const string BombDefuses = "bomb_defuses"; + public const string HostageRescues = "hostage_rescues"; + + // The best killstreak is a maximum, not a running total like the rest. + public const string BestStreak = "best_streak"; + + public static readonly IReadOnlyList All = + [ + Shots, + Hits, + HitsHead, + HitsNeck, + HitsChest, + HitsStomach, + HitsLeftArm, + HitsRightArm, + HitsLeftLeg, + HitsRightLeg, + DamageTaken, + TeamDamage, + HeDamage, + MolotovDamage, + FlashAssists, + EnemiesFlashed, + TeamFlashed, + FlashDurationMs, + KnifeKills, + ZeusKills, + HeKills, + MolotovKills, + NoScopeKills, + ThruSmokeKills, + ThruWallKills, + BlindedKills, + InAirKills, + TeamKills, + Suicides, + TradeKills, + BombPlants, + BombDefuses, + HostageRescues, + BestStreak, + ]; + + // CS2's hitgroup numbers. Generic (0) and gear (10) are hits in no group. + public static string? HitGroup(int hitGroup) + { + return hitGroup switch + { + 1 => HitsHead, + 2 => HitsChest, + 3 => HitsStomach, + 4 => HitsLeftArm, + 5 => HitsRightArm, + 6 => HitsLeftLeg, + 7 => HitsRightLeg, + 8 => HitsNeck, + _ => null, + }; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/CommunityTrades.cs b/shared/dotnet/FiveStack.Utilities/CommunityTrades.cs new file mode 100644 index 00000000..5b32c301 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/CommunityTrades.cs @@ -0,0 +1,49 @@ +namespace FiveStack.Utilities; + +// The round's kills, kept long enough to tell a trade: killing the killer of a +// teammate within 5 s of that teammate's death, in the same round, as the +// match stats' trade_pairs count it. Each pair of a teammate's death and the +// kill that traded it counts once, so killing someone who has just killed two +// teammates trades both, and a killer who dies any way at all has paid for +// the kills they made: where players respawn, killing them again is no second +// trade of the same teammate. Players are told apart by slot, as CountsAsKill does, +// and the time is the game clock, which only means anything within a map. +public class CommunityTrades +{ + public const float Window = 5f; + + private sealed record Kill(int Killer, int Victim, int VictimTeam, float At); + + private readonly List _kills = new(); + + // Returns how many teammates' deaths this kill trades, and keeps the kill, + // which can be traded in turn. The victim's own kills are traded now. + public int Killed(int killer, int killerTeam, int victim, int victimTeam, float at) + { + _kills.RemoveAll(kill => at - kill.At > Window); + + int traded = _kills.Count(kill => + kill.Killer == victim + && kill.VictimTeam == killerTeam + && kill.Victim != killer + && kill.At <= at + ); + + Died(victim); + _kills.Add(new Kill(killer, victim, victimTeam, at)); + + return traded; + } + + // Any death that is not a counted kill: by the world, the bomb, a bot or a + // teammate. + public void Died(int victim) + { + _kills.RemoveAll(kill => kill.Killer == victim); + } + + public void Clear() + { + _kills.Clear(); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs b/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs new file mode 100644 index 00000000..3840a92a --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs @@ -0,0 +1,125 @@ +namespace FiveStack.Utilities; + +// Weapons by the names the match stats keep in player_kills."with": the event's +// weapon without its weapon_ prefix, except that every knife is one knife and +// a silenced gun is one gun with its silencer on or off. +public static class CommunityWeapons +{ + public const string Knife = "knife"; + + // CS2's guns by item definition index. Anything else a player can hold or + // be hurt by (knives, fists, grenades, the taser, the bomb, the world, and + // what other modes add) fires no bullets. + private static readonly Dictionary Firearms = new() + { + [1] = "deagle", + [2] = "elite", + [3] = "fiveseven", + [4] = "glock", + [7] = "ak47", + [8] = "aug", + [9] = "awp", + [10] = "famas", + [11] = "g3sg1", + [13] = "galilar", + [14] = "m249", + [16] = "m4a1", + [17] = "mac10", + [19] = "p90", + [23] = "mp5sd", + [24] = "ump45", + [25] = "xm1014", + [26] = "bizon", + [27] = "mag7", + [28] = "negev", + [29] = "sawedoff", + [30] = "tec9", + [32] = "hkp2000", + [33] = "mp7", + [34] = "mp9", + [35] = "nova", + [36] = "p250", + [38] = "scar20", + [39] = "sg556", + [40] = "ssg08", + [60] = "m4a1_silencer", + [61] = "usp_silencer", + [63] = "cz75a", + [64] = "revolver", + }; + + private static readonly HashSet FirearmNames = [.. Firearms.Values]; + + // Events may name a silenced gun with its silencer off apart, which would + // split one gun's shots, hits and kills over two keys. + private const string SilencerOff = "_silencer_off"; + + // CS2 builds these guns on the class of another, so a name read from the + // entity rather than from the item can be the other gun's: the M4A1-S's + // class is the M4A4's, the USP-S's the P2000's, the CZ75's the P250's, the + // R8's the Deagle's and the MP5-SD's the MP7's. + private static readonly HashSet SharedClasses = + [ + "m4a1", + "hkp2000", + "p250", + "deagle", + "mp7", + ]; + + public static string Normalize(string? weapon) + { + string name = (weapon ?? "").Trim().ToLowerInvariant(); + + if (name.StartsWith("weapon_")) + { + name = name["weapon_".Length..]; + } + + if (name.EndsWith(SilencerOff)) + { + name = name[..^"_off".Length]; + } + + return name.StartsWith(Knife) || name == "bayonet" ? Knife : name; + } + + public static bool IsFirearm(string? weapon) + { + return FirearmNames.Contains(Normalize(weapon)); + } + + public static string? FirearmByDefinition(int index) + { + return Firearms.GetValueOrDefault(index); + } + + // Whether the name may stand for another gun, which only the item can tell. + public static bool SharesAClass(string? weapon) + { + return SharedClasses.Contains(Normalize(weapon)); + } + + // A molotov and an incendiary both kill by their fire, the inferno. + public static string? KillType(string? weapon) + { + return Normalize(weapon) switch + { + Knife => CommunityStatKeys.KnifeKills, + "taser" => CommunityStatKeys.ZeusKills, + "hegrenade" => CommunityStatKeys.HeKills, + "inferno" or "molotov" => CommunityStatKeys.MolotovKills, + _ => null, + }; + } + + public static string? DamageType(string? weapon) + { + return Normalize(weapon) switch + { + "hegrenade" => CommunityStatKeys.HeDamage, + "inferno" or "molotov" => CommunityStatKeys.MolotovDamage, + _ => null, + }; + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 67363875..6aa58b7b 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -5,6 +5,44 @@ namespace FiveStack.Utilities; public sealed record ObservedPlayer(string SteamId, string Name, string? Ip, bool Verified); +// What a death event says beyond who died, for the detailed stats. The killer's +// and victim's slots and teams are there only for a counted kill (a slot of -1 +// otherwise), since a trade is told by slot. TeamKiller is the roster id of a +// player who killed a teammate, and Suicide is a death by one's own hand or the +// world's, as IsSuicide tells it. +public sealed record KillFacts( + string Weapon = "", + float Time = 0, + int KillerSlot = -1, + int KillerTeam = 0, + int VictimSlot = -1, + int VictimTeam = 0, + bool TeammatesAreEnemies = false, + bool NoScope = false, + bool ThruSmoke = false, + bool AttackerBlind = false, + int Penetrated = 0, + bool AttackerInAir = false, + bool AssistedFlash = false, + bool Suicide = false, + string? TeamKiller = null +); + +// What a hurt event says beyond the damage. Enemy is a hit by the rule a kill +// counts by; Team is a human hurting a human, themselves included, who is no +// enemy, which counts for nothing where teammates are enemies. The tick tells +// a shotgun's pellets apart from separate shots. +public sealed record HitFacts( + string Weapon, + int HitGroup, + int Tick, + int VictimSlot, + bool VictimIsHuman, + bool Enemy, + bool Team = false, + bool TeammatesAreEnemies = false +); + public sealed record RosterReport( List? Players, List Departed, @@ -35,6 +73,10 @@ public class PlayerRoster public const int MaxNameLength = 64; + // More than any one connection can use, so a server that names its own + // weapons cannot grow a report without end. + public const int MaxWeapons = 48; + public const int TeamT = 2; public const int TeamCT = 3; @@ -62,6 +104,42 @@ private sealed class Entry public int RoundsPlayed; public int RoundsWon; public int Mvps; + public readonly Dictionary Stats = new(); + public readonly Dictionary Weapons = new(); + public int Streak; + public int BestStreak; + public int HitTick = -1; + public readonly HashSet HitSlots = new(); + + public void Add(string key, int amount = 1) + { + Stats[key] = Stats.GetValueOrDefault(key) + amount; + } + + // Null once the cap is reached, for a weapon the connection has not + // used before. + public WeaponTotals? Weapon(string weapon) + { + string name = CommunityWeapons.Normalize(weapon); + + if (name.Length == 0) + { + return null; + } + + if (!Weapons.TryGetValue(name, out WeaponTotals? totals)) + { + if (Weapons.Count >= MaxWeapons) + { + return null; + } + + totals = new WeaponTotals(); + Weapons[name] = totals; + } + + return totals; + } } private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); @@ -82,6 +160,7 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private string? _map; private RosterReport? _final; private DateTimeOffset _finalAt; + private readonly CommunityTrades _trades = new(); // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) @@ -176,6 +255,14 @@ public void MapStarted(DateTimeOffset now, string? map) Hold(now); _mapStarted = true; _releaseAt = null; + _trades.Clear(); + + // A killstreak is a map's, like the match stats' best streak. + foreach (Entry entry in _current.Values) + { + entry.Streak = 0; + entry.BestStreak = 0; + } } } @@ -206,13 +293,15 @@ public void Hibernating(DateTimeOffset now) // only when the kill or assist counts, so a headshot only ever goes with a // 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. + // each event, however long the warmup runs or however it ends. An assist + // whose facts say it was a flash's is a flash assist and not an assist. public void Died( string? victim, string? killer, bool warmup, string? assister = null, - bool headshot = false + bool headshot = false, + KillFacts? facts = null ) { if (warmup) @@ -225,16 +314,60 @@ public void Died( if (victim != null && _current.TryGetValue(victim, out Entry? dead)) { dead.Deaths++; + dead.Streak = 0; + + if (facts?.Suicide == true) + { + dead.Add(CommunityStatKeys.Suicides); + } + } + + if ( + facts?.TeamKiller != null + && _current.TryGetValue(facts.TeamKiller, out Entry? traitor) + ) + { + traitor.Add(CommunityStatKeys.TeamKills); } if (killer != null && _current.TryGetValue(killer, out Entry? scorer)) { scorer.Kills++; + scorer.Streak++; + scorer.BestStreak = Math.Max(scorer.BestStreak, scorer.Streak); if (headshot) { scorer.Headshots++; } + + if (facts != null) + { + Scored(scorer, facts, headshot); + } + } + + // Every counted kill is kept, whoever made it, since a kill by an + // unverified player can still be traded by one on the roster. Any + // other death still ends what the victim can be traded for. + if (facts != null && facts.KillerSlot >= 0 && !facts.TeammatesAreEnemies) + { + int traded = _trades.Killed( + facts.KillerSlot, + facts.KillerTeam, + facts.VictimSlot, + facts.VictimTeam, + facts.Time + ); + + if (traded > 0 && killer != null && _current.TryGetValue(killer, out Entry? trader)) + { + trader.Add(CommunityStatKeys.TradeKills, traded); + } + } + else if (facts != null && facts.VictimSlot >= 0) + { + _trades.Died(facts.VictimSlot); } if ( @@ -243,29 +376,171 @@ public void Died( && _current.TryGetValue(assister, out Entry? helper) ) { - helper.Assists++; + if (facts?.AssistedFlash == true) + { + helper.Add(CommunityStatKeys.FlashAssists); + } + else + { + helper.Assists++; + } + } + } + } + + // The damage comes already capped by Dealt. Without facts the hooks pass + // an attacker only for damage to an enemy, by the same rule as a kill. + // With them, a hit on any enemy counts, bots included, but only damage to + // a human does; a human's hurting a human who is no enemy, themselves + // included, is team damage, as the match stats count it, but not where + // teammates are enemies. + public void Hurt( + string? attacker, + string? victim, + int damage, + bool warmup, + HitFacts? hit = null + ) + { + if (warmup) + { + return; + } + + if (hit == null) + { + if (attacker == null || attacker == victim || damage <= 0) + { + return; + } + + lock (_lock) + { + if (_current.TryGetValue(attacker, out Entry? entry)) + { + entry.Damage += damage; + } + } + + return; + } + + lock (_lock) + { + if (attacker != null && _current.TryGetValue(attacker, out Entry? entry)) + { + if (hit.Enemy) + { + Hit(entry, hit, damage); + } + else if (hit.Team && !hit.TeammatesAreEnemies && damage > 0) + { + entry.Add(CommunityStatKeys.TeamDamage, damage); + } + } + + if ( + hit.Enemy + && damage > 0 + && victim != null + && _current.TryGetValue(victim, out Entry? hurt) + ) + { + hurt.Add(CommunityStatKeys.DamageTaken, damage); + } + } + } + + // Only a flash that blinds for more than a second counts, as the match + // stats count it, and blinding oneself is blinding a teammate, except where + // teammates are enemies and there are no team stats. The hooks pass only + // human victims. + public void Blinded( + string? attacker, + string? victim, + float seconds, + bool sameTeam, + bool warmup, + bool teammatesAreEnemies = false + ) + { + if (warmup || attacker == null || seconds <= 1.0f) + { + return; + } + + lock (_lock) + { + if (!_current.TryGetValue(attacker, out Entry? entry)) + { + return; + } + + if (sameTeam || attacker == victim) + { + if (!teammatesAreEnemies) + { + entry.Add(CommunityStatKeys.TeamFlashed); + } + + return; + } + + entry.Add(CommunityStatKeys.EnemiesFlashed); + entry.Add(CommunityStatKeys.FlashDurationMs, (int)Math.Round(seconds * 1000)); + } + } + + // A shot is a bullet weapon's: a knife's swing or a grenade's throw is not. + public void Fired(string? steamId, string? weapon, bool warmup) + { + if (warmup || steamId == null || !CommunityWeapons.IsFirearm(weapon)) + { + return; + } + + lock (_lock) + { + if (_current.TryGetValue(steamId, out Entry? entry)) + { + entry.Add(CommunityStatKeys.Shots); + + WeaponTotals? totals = entry.Weapon(weapon!); + + if (totals != null) + { + totals.shots++; + } } } } - // 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, bool warmup) + // A bomb planted or defused, or a hostage rescued, by the stat's key. + public void Objective(string? steamId, string key, bool warmup) { - if (warmup || attacker == null || attacker == victim || damage <= 0) + if (warmup || steamId == null) { return; } lock (_lock) { - if (_current.TryGetValue(attacker, out Entry? entry)) + if (_current.TryGetValue(steamId, out Entry? entry)) { - entry.Damage += damage; + entry.Add(key); } } } + // A trade is a round's. + public void RoundStarted() + { + lock (_lock) + { + _trades.Clear(); + } + } + // 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. @@ -433,6 +708,31 @@ bool teammatesAreEnemies return attackerSlot != victimSlot && (teammatesAreEnemies || attackerTeam != victimTeam); } + // Killing oneself is a suicide, not a team kill, and where teammates are + // enemies nobody is a teammate. + public static bool IsTeamKill( + int attackerSlot, + int victimSlot, + int attackerTeam, + int victimTeam, + bool teammatesAreEnemies + ) + { + return attackerSlot != victimSlot && !teammatesAreEnemies && attackerTeam == victimTeam; + } + + // No attacker is the world's doing, but the bomb has no attacker either, + // and dying to it is no suicide. + public static bool IsSuicide(int? attackerSlot, int victimSlot, string? weapon) + { + if (attackerSlot == null) + { + return CommunityWeapons.Normalize(weapon) != "planted_c4"; + } + + return attackerSlot == victimSlot; + } + // A flash assist only blinded the victim, which did nothing to their health. public static bool CountsAsAssist( int assisterSlot, @@ -464,6 +764,157 @@ public static int Dealt(int damage, int healthAfter, int? healthBefore) return Math.Clamp(damage, 0, Math.Max(before, 0)); } + private static void Scored(Entry scorer, KillFacts facts, bool headshot) + { + string? type = CommunityWeapons.KillType(facts.Weapon); + + if (type != null) + { + scorer.Add(type); + } + + if (facts.NoScope) + { + scorer.Add(CommunityStatKeys.NoScopeKills); + } + + if (facts.ThruSmoke) + { + scorer.Add(CommunityStatKeys.ThruSmokeKills); + } + + if (facts.Penetrated > 0) + { + scorer.Add(CommunityStatKeys.ThruWallKills); + } + + if (facts.AttackerBlind) + { + scorer.Add(CommunityStatKeys.BlindedKills); + } + + if (facts.AttackerInAir) + { + scorer.Add(CommunityStatKeys.InAirKills); + } + + WeaponTotals? totals = scorer.Weapon(facts.Weapon); + + if (totals != null) + { + totals.kills++; + + if (headshot) + { + totals.headshots++; + } + } + } + + // A shotgun's pellets all hit in the same tick, and each victim they hit + // is hit once, as the match stats' hits are shots that hit. + private static void Hit(Entry entry, HitFacts hit, int damage) + { + WeaponTotals? totals = null; + + if (CommunityWeapons.IsFirearm(hit.Weapon)) + { + if (entry.HitTick != hit.Tick) + { + entry.HitTick = hit.Tick; + entry.HitSlots.Clear(); + } + + if (entry.HitSlots.Add(hit.VictimSlot)) + { + entry.Add(CommunityStatKeys.Hits); + + string? group = CommunityStatKeys.HitGroup(hit.HitGroup); + + if (group != null) + { + entry.Add(group); + } + + totals = entry.Weapon(hit.Weapon); + + if (totals != null) + { + totals.hits++; + } + } + } + + if (!hit.VictimIsHuman || damage <= 0) + { + return; + } + + entry.Damage += damage; + + string? type = CommunityWeapons.DamageType(hit.Weapon); + + if (type != null) + { + entry.Add(type, damage); + } + + totals ??= entry.Weapon(hit.Weapon); + + if (totals != null) + { + totals.damage += damage; + } + } + + // Copies, since the report is serialized outside the lock, with zeros + // left out and nothing at all when nothing is left. + private static Dictionary? StatsOf(Entry entry) + { + Dictionary stats = entry + .Stats.Where(stat => stat.Value != 0) + .ToDictionary(stat => stat.Key, stat => stat.Value); + + if (entry.BestStreak > 0) + { + stats[CommunityStatKeys.BestStreak] = entry.BestStreak; + } + + return stats.Count == 0 ? null : stats; + } + + private static Dictionary? WeaponsOf(Entry entry) + { + Dictionary weapons = entry + .Weapons.Where(weapon => + weapon.Value.kills != 0 + || weapon.Value.headshots != 0 + || weapon.Value.shots != 0 + || weapon.Value.hits != 0 + || weapon.Value.damage != 0 + ) + .ToDictionary(weapon => weapon.Key, weapon => Copy(weapon.Value)); + + return weapons.Count == 0 ? null : weapons; + } + + private static Dictionary? Copy(Dictionary? weapons) + { + return weapons?.ToDictionary(weapon => weapon.Key, weapon => Copy(weapon.Value)); + } + + private static WeaponTotals Copy(WeaponTotals totals) + { + return new WeaponTotals + { + kills = totals.kills, + headshots = totals.headshots, + shots = totals.shots, + hits = totals.hits, + damage = totals.damage, + }; + } + // The panel checks the name itself; this only keeps an empty one from // reading as a map. private static string? MapName(string? raw) @@ -490,6 +941,8 @@ private List Players() rounds_played = entry.Value.RoundsPlayed, rounds_won = entry.Value.RoundsWon, mvps = entry.Value.Mvps, + stats = StatsOf(entry.Value), + weapons = WeaponsOf(entry.Value), }) .ToList(); } @@ -510,6 +963,8 @@ private List Departures() rounds_played = departure.Player.rounds_played, rounds_won = departure.Player.rounds_won, mvps = departure.Player.mvps, + stats = departure.Player.stats == null ? null : new(departure.Player.stats), + weapons = Copy(departure.Player.weapons), }) .ToList(); } @@ -607,6 +1062,8 @@ private void Reconcile(DateTimeOffset now) rounds_played = entry.RoundsPlayed, rounds_won = entry.RoundsWon, mvps = entry.Mvps, + stats = StatsOf(entry), + weapons = WeaponsOf(entry), }, now ) From 8179a905ebe045e9fb4114665fa46c599b9a23f2 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sat, 31 Oct 2026 23:33:24 +0100 Subject: [PATCH 06/18] bug: beside bots the community stats count only humans against humans, a shot that hits a bot is taken back, and a shared class gun is named by the gun in hand On a server with humans and bots, nothing a bot does counts and nothing done to a bot does: a bot's kill ends the victim's killstreak but is no death, helping a bot to a kill is no assist, and hits, damage and kills on a bot count for nobody. Everything between humans still counts, since each event names both players. A miss names no target, so a shot is still counted when fired and taken back when its bullet hits a bot, unless the same shot also hits a human enemy in that tick. player_hurt names a USP-S hkp2000, so a gun built on another's class had its hits and damage under the other gun's name. Hits and kills now read the gun in hand for those five names (M4A1-S, USP-S, CZ75, R8 and MP5-SD), as shots already did, and all three take it only when it is the event's gun or the one on its class, so a player who switched guns since keeps the event's name. --- .../src/PlayerManagementPlugin.cs | 76 +++++++----- .../src/PlayerManagementPlugin.cs | 78 +++++++----- .../test/CommunityWeaponsTests.cs | 17 +++ .../test/PlayerRosterTests.cs | 117 +++++++++++++----- .../FiveStack.Utilities/CommunityWeapons.cs | 27 ++++ .../FiveStack.Utilities/PlayerRoster.cs | 85 ++++++++++--- 6 files changed, 301 insertions(+), 99 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index e3558af9..5e3c39f9 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -408,6 +408,10 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) CCSPlayerController? assister = @event.Assister; bool teammatesAreEnemies = TeammatesAreEnemies(); + // Nothing a bot does counts, so a death at a bot's hands only + // ends a streak and helping a bot to a kill is no assist. + bool byBot = attacker != null && IsBot(attacker); + bool kill = attacker != null && IsHuman(attacker) @@ -420,7 +424,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) ); bool assist = - assister != null + !byBot + && assister != null && IsHuman(assister) && PlayerRoster.CountsAsAssist( assister.Slot, @@ -434,6 +439,7 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) // A flash assist is counted apart, by the same rule as a kill. bool flashAssist = @event.Assistedflash + && !byBot && assister != null && IsHuman(assister) && PlayerRoster.CountsAsKill( @@ -456,7 +462,7 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) ); KillFacts facts = new( - Weapon: @event.Weapon, + Weapon: kill ? HeldWeapon(attacker!, @event.Weapon) : @event.Weapon, Time: Server.CurrentTime, KillerSlot: kill ? attacker!.Slot : -1, KillerTeam: kill ? attacker!.TeamNum : 0, @@ -474,7 +480,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) victim.Slot, @event.Weapon ), - TeamKiller: teamKill ? VerifiedSteamIdOf(attacker!) : null + TeamKiller: teamKill ? VerifiedSteamIdOf(attacker!) : null, + ByBot: byBot ); _roster.Died( @@ -513,22 +520,32 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) ); _health[victim.Slot] = @event.Health; - // A hit on a bot counts, damage to one does not, and only a human - // hurting a human who is no enemy is team damage. Nothing counts - // without an attacker, between two bots, or for a bot hurt by - // anything but a gun, which spares those the lookups below. + // Nothing a bot does or has done to it counts, nor anything + // without an attacker. A human's bullet in a bot takes back the + // shot it was. Only a human hurting a human who is no enemy is + // team damage. CCSPlayerController? attacker = @event.Attacker; - if (attacker == null || !attacker.IsValid) + if (attacker == null || !attacker.IsValid || IsBot(attacker)) { return HookResult.Continue; } bool attackerIsHuman = IsHuman(attacker); - bool victimIsHuman = IsHuman(victim); + string weapon = attackerIsHuman ? HeldWeapon(attacker, @event.Weapon) : @event.Weapon; - if (!victimIsHuman && (!attackerIsHuman || !CommunityWeapons.IsFirearm(@event.Weapon))) + if (!IsHuman(victim)) { + if (attackerIsHuman && IsBot(victim)) + { + _roster.HitABot( + VerifiedSteamIdOf(attacker), + weapon, + Server.TickCount, + IsWarmup() + ); + } + return HookResult.Continue; } @@ -544,17 +561,16 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) _roster.Hurt( attackerIsHuman ? VerifiedSteamIdOf(attacker) : null, - victimIsHuman ? VerifiedSteamIdOf(victim) : null, + VerifiedSteamIdOf(victim), dealt, IsWarmup(), new HitFacts( - @event.Weapon, + weapon, @event.Hitgroup, Server.TickCount, victim.Slot, - victimIsHuman, enemy, - attackerIsHuman && victimIsHuman && !enemy, + attackerIsHuman && !enemy, teammatesAreEnemies ) ); @@ -613,7 +629,7 @@ public HookResult OnWeaponFire(EventWeaponFire @event, GameEventInfo info) return HookResult.Continue; } - _roster.Fired(steamId, FiredWeapon(player, @event.Weapon), IsWarmup()); + _roster.Fired(steamId, HeldWeapon(player, @event.Weapon), IsWarmup()); } catch (Exception error) { @@ -798,11 +814,11 @@ private void ObserveRoster(List humans) } } - // Some guns share another's class (see CommunityWeapons), and weapon_fire - // may then name the other gun, while player_hurt and player_death name - // the item. Only for those names is the gun in hand read by its item - // definition index; anything unreadable keeps the event's name. - private static string FiredWeapon(CCSPlayerController player, string weapon) + // Some guns share another's class (see CommunityWeapons), and an event + // may then name the other gun: player_hurt names a USP-S hkp2000. Only + // for those names is the gun in hand read, by its item definition index; + // anything unreadable keeps the event's name. + private static string HeldWeapon(CCSPlayerController player, string weapon) { if (!CommunityWeapons.SharesAClass(weapon)) { @@ -811,14 +827,12 @@ private static string FiredWeapon(CCSPlayerController player, string weapon) CBasePlayerWeapon? active = player.PlayerPawn.Value?.WeaponServices?.ActiveWeapon.Value; - if (active == null || !active.IsValid) - { - return weapon; - } - - return CommunityWeapons.FirearmByDefinition( - active.AttributeManager.Item.ItemDefinitionIndex - ) ?? weapon; + return CommunityWeapons.InHand( + weapon, + active != null && active.IsValid + ? active.AttributeManager.Item.ItemDefinitionIndex + : null + ); } // Unreadable is taken as teams as usual, where a team kill earns nothing. @@ -914,6 +928,12 @@ private static List Observed(List humans) .ToList(); } + // CSTV is a fake client too, and no bot. + private static bool IsBot(CCSPlayerController player) + { + return player.IsValid && player.IsBot && !player.IsHLTV; + } + private static bool IsHuman(CCSPlayerController player) { return player.IsValid && !player.IsBot && !player.IsHLTV && SteamIdOf(player) != 0; diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 40adaaba..ce43252a 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -460,6 +460,10 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) IPlayer? assister = @event.AssisterPlayer; bool teammatesAreEnemies = TeammatesAreEnemies(); + // Nothing a bot does counts, so a death at a bot's hands only + // ends a streak and helping a bot to a kill is no assist. + bool byBot = attacker != null && IsBot(attacker); + bool kill = attacker != null && IsHuman(attacker) @@ -472,7 +476,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) ); bool assist = - assister != null + !byBot + && assister != null && IsHuman(assister) && PlayerRoster.CountsAsAssist( assister.PlayerID, @@ -486,6 +491,7 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) // A flash assist is counted apart, by the same rule as a kill. bool flashAssist = @event.AssistedFlash + && !byBot && assister != null && IsHuman(assister) && PlayerRoster.CountsAsKill( @@ -508,7 +514,7 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) ); KillFacts facts = new( - Weapon: @event.Weapon, + Weapon: kill ? HeldWeapon(attacker!, @event.Weapon) : @event.Weapon, Time: Core.Engine.GlobalVars.CurrentTime, KillerSlot: kill ? attacker!.PlayerID : -1, KillerTeam: kill ? attacker!.Controller.TeamNum : 0, @@ -526,7 +532,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) victim.PlayerID, @event.Weapon ), - TeamKiller: teamKill ? VerifiedSteamIdOf(attacker!) : null + TeamKiller: teamKill ? VerifiedSteamIdOf(attacker!) : null, + ByBot: byBot ); _roster.Died( @@ -565,22 +572,32 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) ); _health[victim.PlayerID] = @event.ActualHealth; - // A hit on a bot counts, damage to one does not, and only a human - // hurting a human who is no enemy is team damage. Nothing counts - // without an attacker, between two bots, or for a bot hurt by - // anything but a gun, which spares those the lookups below. + // Nothing a bot does or has done to it counts, nor anything + // without an attacker. A human's bullet in a bot takes back the + // shot it was. Only a human hurting a human who is no enemy is + // team damage. IPlayer? attacker = @event.AttackerPlayer; - if (attacker == null || !attacker.IsValid) + if (attacker == null || !attacker.IsValid || IsBot(attacker)) { return HookResult.Continue; } bool attackerIsHuman = IsHuman(attacker); - bool victimIsHuman = IsHuman(victim); + string weapon = attackerIsHuman ? HeldWeapon(attacker, @event.Weapon) : @event.Weapon; - if (!victimIsHuman && (!attackerIsHuman || !CommunityWeapons.IsFirearm(@event.Weapon))) + if (!IsHuman(victim)) { + if (attackerIsHuman && IsBot(victim)) + { + _roster.HitABot( + VerifiedSteamIdOf(attacker), + weapon, + Core.Engine.GlobalVars.TickCount, + IsWarmup() + ); + } + return HookResult.Continue; } @@ -596,17 +613,16 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) _roster.Hurt( attackerIsHuman ? VerifiedSteamIdOf(attacker) : null, - victimIsHuman ? VerifiedSteamIdOf(victim) : null, + VerifiedSteamIdOf(victim), dealt, IsWarmup(), new HitFacts( - @event.Weapon, + weapon, (int)@event.ActualHitGroup, Core.Engine.GlobalVars.TickCount, victim.PlayerID, - victimIsHuman, enemy, - attackerIsHuman && victimIsHuman && !enemy, + attackerIsHuman && !enemy, teammatesAreEnemies ) ); @@ -665,7 +681,7 @@ public HookResult OnWeaponFire(EventWeaponFire @event) return HookResult.Continue; } - _roster.Fired(steamId, FiredWeapon(player, @event.Weapon), IsWarmup()); + _roster.Fired(steamId, HeldWeapon(player, @event.Weapon), IsWarmup()); } catch (Exception error) { @@ -850,11 +866,11 @@ private void ObserveRoster(List humans) } } - // Some guns share another's class (see CommunityWeapons), and weapon_fire - // may then name the other gun, while player_hurt and player_death name - // the item. Only for those names is the gun in hand read by its item - // definition index; anything unreadable keeps the event's name. - private static string FiredWeapon(IPlayer player, string weapon) + // Some guns share another's class (see CommunityWeapons), and an event + // may then name the other gun: player_hurt names a USP-S hkp2000. Only + // for those names is the gun in hand read, by its item definition index; + // anything unreadable keeps the event's name. + private static string HeldWeapon(IPlayer player, string weapon) { if (!CommunityWeapons.SharesAClass(weapon)) { @@ -863,14 +879,12 @@ private static string FiredWeapon(IPlayer player, string weapon) CBasePlayerWeapon? active = player.PlayerPawn?.WeaponServices?.ActiveWeapon.Value; - if (active == null || !active.IsValid) - { - return weapon; - } - - return CommunityWeapons.FirearmByDefinition( - active.AttributeManager.Item.ItemDefinitionIndex - ) ?? weapon; + return CommunityWeapons.InHand( + weapon, + active != null && active.IsValid + ? active.AttributeManager.Item.ItemDefinitionIndex + : null + ); } // Unreadable is taken as teams as usual, where a team kill earns nothing. @@ -1012,6 +1026,14 @@ private static List Observed(List humans) .ToList(); } + // CSTV is a fake client too, and no bot. + private static bool IsBot(IPlayer player) + { + CCSPlayerController controller = player.Controller; + + return controller.IsValid && player.IsFakeClient && !controller.IsHLTV; + } + // Not IPlayer.IsValid: that also wants a pawn, and a player without one // is still on the server, so the roster ended their session and a gag // stopped applying until they had one again. diff --git a/apps/player-management-sw/test/CommunityWeaponsTests.cs b/apps/player-management-sw/test/CommunityWeaponsTests.cs index 5361ee23..c9d5a952 100644 --- a/apps/player-management-sw/test/CommunityWeaponsTests.cs +++ b/apps/player-management-sw/test/CommunityWeaponsTests.cs @@ -137,4 +137,21 @@ public void SomeGunsShareTheirClassWithAnother(string weapon, bool expected) { Assert.Equal(expected, CommunityWeapons.SharesAClass(weapon)); } + + [Theory] + [InlineData("hkp2000", 61, "usp_silencer")] + [InlineData("hkp2000", 32, "hkp2000")] + [InlineData("weapon_m4a1", 60, "m4a1_silencer")] + [InlineData("p250", 63, "cz75a")] + [InlineData("deagle", 64, "revolver")] + [InlineData("mp7", 23, "mp5sd")] + [InlineData("hkp2000", 7, "hkp2000")] + [InlineData("hkp2000", 63, "hkp2000")] + [InlineData("hkp2000", 42, "hkp2000")] + [InlineData("hkp2000", null, "hkp2000")] + [InlineData("ak47", 61, "ak47")] + public void TheGunInHandNamesOnlyItsOwnClass(string weapon, int? index, string expected) + { + Assert.Equal(expected, CommunityWeapons.InHand(weapon, index)); + } } diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index a6c89b32..7c97a2bc 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1544,11 +1544,10 @@ private static HitFacts Hit( string weapon = "ak47", int hitGroup = 2, int tick = 100, - int victim = 2, - bool human = true + int victim = 2 ) { - return new HitFacts(weapon, hitGroup, tick, victim, human, Enemy: true); + return new HitFacts(weapon, hitGroup, tick, victim, Enemy: true); } [Fact] @@ -1744,6 +1743,25 @@ public void TheBestStreakIsCountedKillsBetweenDeaths() Assert.False(StatsOf(report, "2").ContainsKey(CommunityStatKeys.BestStreak)); } + // A death at a bot's hands is no death, but the streak still ends there. + [Fact] + public void ADeathAtABotsHandsEndsTheStreakAndIsNoDeath() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Died("2", "1", warmup: false); + roster.Died("2", "1", warmup: false); + roster.Died("1", null, warmup: false, facts: new KillFacts(Suicide: true, ByBot: true)); + roster.Died("2", "1", warmup: false); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(2, StatsOf(report, "1")[CommunityStatKeys.BestStreak]); + Assert.Equal(0, CountersOf(report, "1").Deaths); + Assert.False(StatsOf(report, "1").ContainsKey(CommunityStatKeys.Suicides)); + } + [Fact] public void ANewMapStartsTheStreakOver() { @@ -1886,29 +1904,74 @@ public void PelletsInOneTickAreOneHitOnEachVictim() Assert.Equal(80, WeaponOf(report, "1", "nova")!.damage); } - // Shots and hits on bots count; damage to them does not. + // A shot that hits a bot is taken back, once however many pellets hit; + // a miss stays a shot, and a hit on a human beside a bot stays a hit. [Fact] - public void AHitOnABotIsAHitAndNoDamage() + public void AShotThatHitsABotIsTakenBack() { PlayerRoster roster = new(); - roster.Observe([Human("1")], Start); + roster.Observe([Human("1"), Human("2")], Start); - roster.Hurt("1", null, 40, warmup: false, Hit(hitGroup: 1, victim: 5, human: false)); - roster.Hurt("1", null, 40, warmup: false, Hit("hegrenade", victim: 5, human: false)); roster.Fired("1", "weapon_ak47", warmup: false); + roster.Fired("1", "weapon_ak47", warmup: false); + roster.Fired("1", "weapon_ak47", warmup: false); + roster.HitABot("1", "ak47", 100, warmup: false); + roster.Hurt("1", "2", 27, warmup: false, Hit(tick: 101)); + + roster.Fired("1", "weapon_nova", warmup: false); + roster.HitABot("1", "nova", 200, warmup: false); + roster.HitABot("1", "nova", 200, warmup: false); RosterReport report = roster.Report(At(1)); Dictionary stats = StatsOf(report, "1"); - Assert.Equal(1, stats[CommunityStatKeys.Shots]); + Assert.Equal(2, stats[CommunityStatKeys.Shots]); Assert.Equal(1, stats[CommunityStatKeys.Hits]); - Assert.Equal(1, stats[CommunityStatKeys.HitsHead]); - Assert.False(stats.ContainsKey(CommunityStatKeys.HeDamage)); - Assert.Equal(0, CountersOf(report, "1").Damage); - Assert.Equal(1, WeaponOf(report, "1", "ak47")!.shots); + Assert.Equal(2, WeaponOf(report, "1", "ak47")!.shots); Assert.Equal(1, WeaponOf(report, "1", "ak47")!.hits); - Assert.Equal(0, WeaponOf(report, "1", "ak47")!.damage); - Assert.Null(WeaponOf(report, "1", "hegrenade")); + Assert.Equal(27, CountersOf(report, "1").Damage); + Assert.Null(WeaponOf(report, "1", "nova")); + } + + // A shotgun's pellets that hit a bot and a human in one tick are a shot + // that hit, whichever the event names first. + [Fact] + public void AShotThatHitsABotAndAHumanStaysAShot() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + roster.Fired("1", "weapon_nova", warmup: false); + roster.HitABot("1", "nova", 100, warmup: false); + roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 100)); + roster.Fired("1", "weapon_nova", warmup: false); + roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 200)); + roster.HitABot("1", "nova", 200, warmup: false); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(2, StatsOf(report, "1")[CommunityStatKeys.Shots]); + Assert.Equal(2, WeaponOf(report, "1", "nova")!.shots); + Assert.Equal(2, WeaponOf(report, "1", "nova")!.hits); + } + + // Nothing is taken back that was never counted, nor in warmup. + [Fact] + public void AHitOnABotNeverTakesBackMoreThanTheShots() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.HitABot("1", "deagle", 100, warmup: false); + roster.Fired("1", "weapon_deagle", warmup: false); + roster.HitABot("1", "deagle", 101, warmup: true); + roster.HitABot("1", "knife", 102, warmup: false); + roster.HitABot(null, "deagle", 103, warmup: false); + + RosterReport report = roster.Report(At(1)); + + Assert.Equal(1, StatsOf(report, "1")[CommunityStatKeys.Shots]); + Assert.Equal(1, WeaponOf(report, "1", "deagle")!.shots); } // Only bullets hit: a knife, a grenade or a fire does damage but is no hit. @@ -1944,8 +2007,8 @@ public void TeamAndSelfDamageCountAsTeamDamageOnly() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2")], Start); - HitFacts team = new("ak47", 2, 100, 2, true, Enemy: false, Team: true); - HitFacts self = new("hegrenade", 0, 101, 1, true, Enemy: false, Team: true); + HitFacts team = new("ak47", 2, 100, 2, Enemy: false, Team: true); + HitFacts self = new("hegrenade", 0, 101, 1, Enemy: false, Team: true); roster.Hurt("1", "2", 25, warmup: false, team); roster.Hurt("1", "1", 40, warmup: false, self); @@ -1962,8 +2025,8 @@ public void TeamAndSelfDamageCountAsTeamDamageOnly() Assert.Null(report.Players!.Single(player => player.steam_id == "1").weapons); } - // A bot or an unverified enemy still hurts; the world and oneself do not - // count as damage taken. + // An unverified enemy still hurts; the world and oneself do not count as + // damage taken. [Fact] public void DamageTakenIsWhatAnyEnemyDealt() { @@ -1975,24 +2038,23 @@ public void DamageTakenIsWhatAnyEnemyDealt() "2", 30, warmup: false, - new HitFacts("ak47", 2, 100, 2, true, Enemy: true) + new HitFacts("ak47", 2, 100, 2, Enemy: true) ); - roster.Hurt(null, "2", 12, warmup: false, new HitFacts("", 0, 101, 2, true, Enemy: false)); + roster.Hurt(null, "2", 12, warmup: false, new HitFacts("", 0, 101, 2, Enemy: false)); roster.Hurt( "2", "2", 40, warmup: false, - new HitFacts("hegrenade", 0, 102, 2, true, Enemy: false, Team: true) + new HitFacts("hegrenade", 0, 102, 2, Enemy: false, Team: true) ); Assert.Equal(30, StatsOf(roster.Report(At(1)), "2")[CommunityStatKeys.DamageTaken]); } - // A hit on a teammate is a hit on nobody the player was aiming for, and a - // bot teammate's health is no team damage. + // A hit on a teammate is a hit on nobody the player was aiming for. [Fact] - public void AHitOnATeammateOrABotTeammateIsNoHit() + public void AHitOnATeammateIsNoHit() { PlayerRoster roster = new(); roster.Observe([Human("1")], Start); @@ -2002,7 +2064,7 @@ public void AHitOnATeammateOrABotTeammateIsNoHit() null, 25, warmup: false, - new HitFacts("ak47", 2, 100, 5, false, Enemy: false) + new HitFacts("ak47", 2, 100, 5, Enemy: false) ); Assert.Null(roster.Report(At(1)).Players!.Single().stats); @@ -2143,7 +2205,7 @@ public void NoDetailedStatIsCountedInWarmup() "1", 50, warmup: true, - new HitFacts("hegrenade", 0, 1, 1, true, Enemy: false, Team: true) + new HitFacts("hegrenade", 0, 1, 1, Enemy: false, Team: true) ); roster.Fired("1", "weapon_ak47", warmup: true); roster.Blinded("1", "2", 3f, sameTeam: false, warmup: true); @@ -2390,7 +2452,6 @@ public void ThereAreNoTeamStatsWhereTeammatesAreEnemies() 0, 100, 1, - true, Enemy: false, Team: true, TeammatesAreEnemies: true diff --git a/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs b/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs index 3840a92a..0f83e220 100644 --- a/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs +++ b/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs @@ -100,6 +100,33 @@ public static bool SharesAClass(string? weapon) return SharedClasses.Contains(Normalize(weapon)); } + // The gun on the class of another, by the class's gun. + private static readonly Dictionary OnAClass = new() + { + ["m4a1_silencer"] = "m4a1", + ["usp_silencer"] = "hkp2000", + ["cz75a"] = "p250", + ["revolver"] = "deagle", + ["mp5sd"] = "mp7", + }; + + // The event's gun by the item in hand, which is taken only when it is that + // gun or the one on its class: a player who switched guns since keeps the + // event's name. + public static string InHand(string weapon, int? definitionIndex) + { + if (!SharesAClass(weapon) || definitionIndex == null) + { + return weapon; + } + + string? held = FirearmByDefinition(definitionIndex.Value); + + return held != null && OnAClass.GetValueOrDefault(held, held) == Normalize(weapon) + ? held + : weapon; + } + // A molotov and an incendiary both kill by their fire, the inferno. public static string? KillType(string? weapon) { diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 6aa58b7b..f3da73ec 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -9,7 +9,8 @@ public sealed record ObservedPlayer(string SteamId, string Name, string? Ip, boo // and victim's slots and teams are there only for a counted kill (a slot of -1 // otherwise), since a trade is told by slot. TeamKiller is the roster id of a // player who killed a teammate, and Suicide is a death by one's own hand or the -// world's, as IsSuicide tells it. +// world's, as IsSuicide tells it. ByBot is a death at a bot's hands, which ends +// a streak and is no death. public sealed record KillFacts( string Weapon = "", float Time = 0, @@ -25,19 +26,20 @@ public sealed record KillFacts( bool AttackerInAir = false, bool AssistedFlash = false, bool Suicide = false, - string? TeamKiller = null + string? TeamKiller = null, + bool ByBot = false ); -// What a hurt event says beyond the damage. Enemy is a hit by the rule a kill -// counts by; Team is a human hurting a human, themselves included, who is no -// enemy, which counts for nothing where teammates are enemies. The tick tells -// a shotgun's pellets apart from separate shots. +// What a hurt event says beyond the damage, which the hooks pass only for a +// human victim and an attacker who is no bot. Enemy is a hit by the rule a +// kill counts by; Team is a human hurting a human, themselves included, who is +// no enemy, which counts for nothing where teammates are enemies. The tick +// tells a shotgun's pellets apart from separate shots. public sealed record HitFacts( string Weapon, int HitGroup, int Tick, int VictimSlot, - bool VictimIsHuman, bool Enemy, bool Team = false, bool TeammatesAreEnemies = false @@ -109,6 +111,7 @@ private sealed class Entry public int Streak; public int BestStreak; public int HitTick = -1; + public int BotHitTick = -1; public readonly HashSet HitSlots = new(); public void Add(string key, int amount = 1) @@ -313,12 +316,16 @@ public void Died( { if (victim != null && _current.TryGetValue(victim, out Entry? dead)) { - dead.Deaths++; dead.Streak = 0; - if (facts?.Suicide == true) + if (facts?.ByBot != true) { - dead.Add(CommunityStatKeys.Suicides); + dead.Deaths++; + + if (facts?.Suicide == true) + { + dead.Add(CommunityStatKeys.Suicides); + } } } @@ -390,10 +397,9 @@ public void Died( // The damage comes already capped by Dealt. Without facts the hooks pass // an attacker only for damage to an enemy, by the same rule as a kill. - // With them, a hit on any enemy counts, bots included, but only damage to - // a human does; a human's hurting a human who is no enemy, themselves - // included, is team damage, as the match stats count it, but not where - // teammates are enemies. + // With them, a hit on an enemy counts; a human's hurting a human who is no + // enemy, themselves included, is team damage, as the match stats count + // it, but not where teammates are enemies. Nothing to or by a bot counts. public void Hurt( string? attacker, string? victim, @@ -515,6 +521,41 @@ public void Fired(string? steamId, string? weapon, bool warmup) } } + // Nothing aimed at a bot counts, so a shot that hits one is taken back; a + // miss names no target and stays a shot. A shotgun's pellets in one tick + // are one shot, and one that also hits a human stays a shot (see Hit). + public void HitABot(string? attacker, string? weapon, int tick, bool warmup) + { + if (warmup || attacker == null || !CommunityWeapons.IsFirearm(weapon)) + { + return; + } + + lock (_lock) + { + if ( + !_current.TryGetValue(attacker, out Entry? entry) + || entry.HitTick == tick + || entry.BotHitTick == tick + ) + { + return; + } + + WeaponTotals? totals = entry.Weapon(weapon!); + + // Never below nothing, for a shot fired before it could count. + if (totals == null || totals.shots == 0) + { + return; + } + + totals.shots--; + entry.Add(CommunityStatKeys.Shots, -1); + entry.BotHitTick = tick; + } + } + // A bomb planted or defused, or a hostage rescued, by the stat's key. public void Objective(string? steamId, string key, bool warmup) { @@ -823,6 +864,20 @@ private static void Hit(Entry entry, HitFacts hit, int damage) { entry.HitTick = hit.Tick; entry.HitSlots.Clear(); + + // The same pellets hit a bot first, which took the shot back. + if (entry.BotHitTick == hit.Tick) + { + entry.BotHitTick = -1; + entry.Add(CommunityStatKeys.Shots); + + WeaponTotals? shot = entry.Weapon(hit.Weapon); + + if (shot != null) + { + shot.shots++; + } + } } if (entry.HitSlots.Add(hit.VictimSlot)) @@ -845,7 +900,7 @@ private static void Hit(Entry entry, HitFacts hit, int damage) } } - if (!hit.VictimIsHuman || damage <= 0) + if (damage <= 0) { return; } From f58b7015c5a3fb699f479dbe2cf9afdcd848f954 Mon Sep 17 00:00:00 2001 From: Flegma Date: Sun, 1 Nov 2026 14:10:33 +0100 Subject: [PATCH 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 2678ab5a49c161c240593fe595dd9bfe5cadd5ce Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 11:09:37 +0100 Subject: [PATCH 13/18] fix: keep CounterStrikeSharp 1.0.378 from #282's base --- apps/player-management-css/src/PlayerManagement.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index ffe83b92..b54c7623 100644 --- a/apps/player-management-css/src/PlayerManagement.csproj +++ b/apps/player-management-css/src/PlayerManagement.csproj @@ -5,7 +5,7 @@ PlayerManagement - + From 12b87f07e65685d93b14b55a875442ecfd36688b Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:25:01 +0100 Subject: [PATCH 14/18] 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 f6728e3f62f6f5d01e94b04dd5f1ef88fccdf4d9 Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:30:35 +0100 Subject: [PATCH 15/18] bug: no shot or hit counts while a bot is on the server, and the game rules are looked up once a map rather than on every shot --- .../src/PlayerManagementPlugin.cs | 50 ++++++----- .../src/PlayerManagementPlugin.cs | 47 ++++++----- .../test/PlayerRosterTests.cs | 84 ++++++------------- .../FiveStack.Utilities/PlayerRoster.cs | 63 ++++---------- 4 files changed, 95 insertions(+), 149 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index 6f9ee400..e33dc19f 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -35,6 +35,9 @@ public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig(() => _roster.MapEnded(DateTimeOffset.UtcNow)); + RegisterListener(() => + { + _gameRules = null; + _roster.MapEnded(DateTimeOffset.UtcNow); + }); RegisterListener(map => - _roster.MapStarted(DateTimeOffset.UtcNow, map) - ); + { + _gameRules = null; + _roster.MapStarted(DateTimeOffset.UtcNow, map); + }); RegisterListener(hibernating => { @@ -270,6 +279,7 @@ private void OnTick() List humans = Humans(); ObserveRoster(humans); + _roster.BotsPresent(Utilities.GetPlayers().Any(IsBot)); ReadHealth(humans); Enforce(humans); } @@ -523,9 +533,8 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) int dealt = _health.Hit(victim.Slot, @event.DmgHealth, @event.Health); // Nothing a bot does or has done to it counts, nor anything - // without an attacker. A human's bullet in a bot takes back the - // shot it was. Only a human hurting a human who is no enemy is - // team damage. + // without an attacker. Only a human hurting a human who is no + // enemy is team damage. CCSPlayerController? attacker = @event.Attacker; if (attacker == null || !attacker.IsValid || IsBot(attacker)) @@ -538,16 +547,6 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) if (!IsHuman(victim)) { - if (attackerIsHuman && IsBot(victim)) - { - _roster.HitABot( - VerifiedSteamIdOf(attacker), - weapon, - Server.TickCount, - IsWarmup() - ); - } - return HookResult.Continue; } @@ -909,19 +908,24 @@ private static bool TeammatesAreEnemies() } } - // Unreadable is taken as live, as a server without warmup is. - private static bool IsWarmup() + // Unreadable is taken as live, as a server without warmup is. The game + // rules last as long as the map, so only the lookup is kept, and the flag + // is read fresh each time. + private bool IsWarmup() { try { - return Utilities - .FindAllEntitiesByDesignerName("cs_gamerules") - .FirstOrDefault() - ?.GameRules?.WarmupPeriod - ?? false; + _gameRules ??= Utilities + .FindAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault() + ?.GameRules; + + return _gameRules?.WarmupPeriod ?? false; } catch (Exception) { + _gameRules = null; + return false; } } diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index 10cb4308..a50d4a6d 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -45,6 +45,9 @@ public partial class PlayerManagementPlugin : BasePlugin private readonly ServerAccessBook _access = new(); private readonly PlayerRoster _roster = new(); + // Looked up once a map rather than on every shot and hit. + private CCSGameRules? _gameRules; + // What was last applied to each player present, so changes are announced // once, and whose mute bit this plugin set, so it only ever lifts its own // and never one an admin plugin on the same server set. @@ -136,10 +139,18 @@ public override void Load(bool hotReload) }; Core.Event.OnClientDisconnected += _disconnectHandler; - _mapUnloadHandler = _ => _roster.MapEnded(DateTimeOffset.UtcNow); + _mapUnloadHandler = _ => + { + _gameRules = null; + _roster.MapEnded(DateTimeOffset.UtcNow); + }; Core.Event.OnMapUnload += _mapUnloadHandler; - _mapLoadHandler = @event => _roster.MapStarted(DateTimeOffset.UtcNow, @event.MapName); + _mapLoadHandler = @event => + { + _gameRules = null; + _roster.MapStarted(DateTimeOffset.UtcNow, @event.MapName); + }; Core.Event.OnMapLoad += _mapLoadHandler; _worldUpdateHandler = OnWorldUpdate; @@ -335,6 +346,7 @@ private void OnTick() List humans = Humans(); ObserveRoster(humans); + _roster.BotsPresent(Core.PlayerManager.GetAllPlayers().Any(IsBot)); ReadHealth(humans); Enforce(humans); } @@ -575,9 +587,8 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) int dealt = _health.Hit(victim.PlayerID, @event.ActualDmgHealth, @event.ActualHealth); // Nothing a bot does or has done to it counts, nor anything - // without an attacker. A human's bullet in a bot takes back the - // shot it was. Only a human hurting a human who is no enemy is - // team damage. + // without an attacker. Only a human hurting a human who is no + // enemy is team damage. IPlayer? attacker = @event.AttackerPlayer; if (attacker == null || !attacker.IsValid || IsBot(attacker)) @@ -590,16 +601,6 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) if (!IsHuman(victim)) { - if (attackerIsHuman && IsBot(victim)) - { - _roster.HitABot( - VerifiedSteamIdOf(attacker), - weapon, - Core.Engine.GlobalVars.TickCount, - IsWarmup() - ); - } - return HookResult.Continue; } @@ -961,18 +962,24 @@ private bool TeammatesAreEnemies() } } - // Unreadable is taken as live, as a server without warmup is. + // Unreadable is taken as live, as a server without warmup is. The game + // rules last as long as the map, so only the lookup is kept, and the flag + // is read fresh each time. private bool IsWarmup() { try { - return Core.EntitySystem.GetAllEntitiesByDesignerName("cs_gamerules") - .FirstOrDefault() - ?.GameRules?.WarmupPeriod - ?? false; + _gameRules ??= Core + .EntitySystem.GetAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault() + ?.GameRules; + + return _gameRules?.WarmupPeriod ?? false; } catch (Exception) { + _gameRules = null; + return false; } } diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index aef88a6a..d7c0466a 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -2236,74 +2236,44 @@ public void PelletsInOneTickAreOneHitOnEachVictim() Assert.Equal(80, WeaponOf(report, "1", "nova")!.damage); } - // A shot that hits a bot is taken back, once however many pellets hit; - // a miss stays a shot, and a hit on a human beside a bot stays a hit. + // A miss aimed at a bot cannot be told from one aimed at a human, so while + // a bot is on the server no shot or hit counts, damage and kills against + // humans still do, and once the bots have gone shots and hits count again. [Fact] - public void AShotThatHitsABotIsTakenBack() + public void NoShotOrHitCountsWhileABotIsOnTheServer() { PlayerRoster roster = new(); roster.Observe([Human("1"), Human("2")], Start); + roster.BotsPresent(true); roster.Fired("1", "weapon_ak47", warmup: false); roster.Fired("1", "weapon_ak47", warmup: false); - roster.Fired("1", "weapon_ak47", warmup: false); - roster.HitABot("1", "ak47", 100, warmup: false); - roster.Hurt("1", "2", 27, warmup: false, Hit(tick: 101)); - - roster.Fired("1", "weapon_nova", warmup: false); - roster.HitABot("1", "nova", 200, warmup: false); - roster.HitABot("1", "nova", 200, warmup: false); - - RosterReport report = roster.Report(At(1)); - Dictionary stats = StatsOf(report, "1"); - - Assert.Equal(2, stats[CommunityStatKeys.Shots]); - Assert.Equal(1, stats[CommunityStatKeys.Hits]); - Assert.Equal(2, WeaponOf(report, "1", "ak47")!.shots); - Assert.Equal(1, WeaponOf(report, "1", "ak47")!.hits); - Assert.Equal(27, CountersOf(report, "1").Damage); - Assert.Null(WeaponOf(report, "1", "nova")); - } - - // A shotgun's pellets that hit a bot and a human in one tick are a shot - // that hit, whichever the event names first. - [Fact] - public void AShotThatHitsABotAndAHumanStaysAShot() - { - PlayerRoster roster = new(); - roster.Observe([Human("1"), Human("2")], Start); - - roster.Fired("1", "weapon_nova", warmup: false); - roster.HitABot("1", "nova", 100, warmup: false); - roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 100)); - roster.Fired("1", "weapon_nova", warmup: false); - roster.Hurt("1", "2", 20, warmup: false, Hit("nova", tick: 200)); - roster.HitABot("1", "nova", 200, warmup: false); + roster.Hurt("1", "2", 27, warmup: false, Hit(hitGroup: 1, tick: 100)); + roster.Died("2", "1", warmup: false, headshot: true); - RosterReport report = roster.Report(At(1)); - - Assert.Equal(2, StatsOf(report, "1")[CommunityStatKeys.Shots]); - Assert.Equal(2, WeaponOf(report, "1", "nova")!.shots); - Assert.Equal(2, WeaponOf(report, "1", "nova")!.hits); - } - - // Nothing is taken back that was never counted, nor in warmup. - [Fact] - public void AHitOnABotNeverTakesBackMoreThanTheShots() - { - PlayerRoster roster = new(); - roster.Observe([Human("1")], Start); + RosterReport withBots = roster.Report(At(1)); + Dictionary stats = StatsOf(withBots, "1"); - roster.HitABot("1", "deagle", 100, warmup: false); - roster.Fired("1", "weapon_deagle", warmup: false); - roster.HitABot("1", "deagle", 101, warmup: true); - roster.HitABot("1", "knife", 102, warmup: false); - roster.HitABot(null, "deagle", 103, warmup: false); + Assert.False(stats.ContainsKey(CommunityStatKeys.Shots)); + Assert.False(stats.ContainsKey(CommunityStatKeys.Hits)); + Assert.False(stats.ContainsKey(CommunityStatKeys.HitsHead)); + Assert.Equal(0, WeaponOf(withBots, "1", "ak47")!.shots); + Assert.Equal(0, WeaponOf(withBots, "1", "ak47")!.hits); + Assert.Equal(27, WeaponOf(withBots, "1", "ak47")!.damage); + Assert.Equal(27, CountersOf(withBots, "1").Damage); + Assert.Equal(1, CountersOf(withBots, "1").Kills); + + roster.BotsPresent(false); + roster.Fired("1", "weapon_ak47", warmup: false); + roster.Hurt("1", "2", 30, warmup: false, Hit(hitGroup: 1, tick: 200)); - RosterReport report = roster.Report(At(1)); + RosterReport withoutBots = roster.Report(At(2)); - Assert.Equal(1, StatsOf(report, "1")[CommunityStatKeys.Shots]); - Assert.Equal(1, WeaponOf(report, "1", "deagle")!.shots); + Assert.Equal(1, StatsOf(withoutBots, "1")[CommunityStatKeys.Shots]); + Assert.Equal(1, StatsOf(withoutBots, "1")[CommunityStatKeys.Hits]); + Assert.Equal(1, WeaponOf(withoutBots, "1", "ak47")!.shots); + Assert.Equal(1, WeaponOf(withoutBots, "1", "ak47")!.hits); + Assert.Equal(57, CountersOf(withoutBots, "1").Damage); } // Only bullets hit: a knife, a grenade or a fire does damage but is no hit. diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index db219671..7a732377 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -111,7 +111,6 @@ private sealed class Entry public int Streak; public int BestStreak; public int HitTick = -1; - public int BotHitTick = -1; public readonly HashSet HitSlots = new(); public void Add(string key, int amount = 1) @@ -171,6 +170,8 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); // The side each player last spawned on since the last round ended. private readonly Dictionary _spawned = new(); + private bool _botsPresent; + // Returns when a sync is due if this observation is what made one due. public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now) { @@ -447,7 +448,7 @@ public void Hurt( { if (hit.Enemy) { - Hit(entry, hit, damage); + Hit(entry, hit, damage, _botsPresent); } else if (hit.Team && !hit.TeammatesAreEnemies && damage > 0) { @@ -517,7 +518,7 @@ public void Fired(string? steamId, string? weapon, bool warmup) lock (_lock) { - if (_current.TryGetValue(steamId, out Entry? entry)) + if (!_botsPresent && _current.TryGetValue(steamId, out Entry? entry)) { entry.Add(CommunityStatKeys.Shots); @@ -531,38 +532,15 @@ public void Fired(string? steamId, string? weapon, bool warmup) } } - // Nothing aimed at a bot counts, so a shot that hits one is taken back; a - // miss names no target and stays a shot. A shotgun's pellets in one tick - // are one shot, and one that also hits a human stays a shot (see Hit). - public void HitABot(string? attacker, string? weapon, int tick, bool warmup) + // A shot a human aims at a bot is no shot at all, but a miss names no + // target, so nothing tells it from a miss aimed at a human. While a bot is + // on the server no shot or hit counts, and accuracy is read off the rounds + // fought against humans alone. Damage and kills against humans still count. + public void BotsPresent(bool present) { - if (warmup || attacker == null || !CommunityWeapons.IsFirearm(weapon)) - { - return; - } - lock (_lock) { - if ( - !_current.TryGetValue(attacker, out Entry? entry) - || entry.HitTick == tick - || entry.BotHitTick == tick - ) - { - return; - } - - WeaponTotals? totals = entry.Weapon(weapon!); - - // Never below nothing, for a shot fired before it could count. - if (totals == null || totals.shots == 0) - { - return; - } - - totals.shots--; - entry.Add(CommunityStatKeys.Shots, -1); - entry.BotHitTick = tick; + _botsPresent = present; } } @@ -934,31 +912,18 @@ private static void Scored(Entry scorer, KillFacts facts, bool headshot) } // A shotgun's pellets all hit in the same tick, and each victim they hit - // is hit once, as the match stats' hits are shots that hit. - private static void Hit(Entry entry, HitFacts hit, int damage) + // is hit once, as the match stats' hits are shots that hit. While a bot is + // on the server only the damage counts (see BotsPresent). + private static void Hit(Entry entry, HitFacts hit, int damage, bool botsPresent) { WeaponTotals? totals = null; - if (CommunityWeapons.IsFirearm(hit.Weapon)) + if (!botsPresent && CommunityWeapons.IsFirearm(hit.Weapon)) { if (entry.HitTick != hit.Tick) { entry.HitTick = hit.Tick; entry.HitSlots.Clear(); - - // The same pellets hit a bot first, which took the shot back. - if (entry.BotHitTick == hit.Tick) - { - entry.BotHitTick = -1; - entry.Add(CommunityStatKeys.Shots); - - WeaponTotals? shot = entry.Weapon(hit.Weapon); - - if (shot != null) - { - shot.shots++; - } - } } if (entry.HitSlots.Add(hit.VictimSlot)) From fdd2d91c8de1078fdb5cd16b25aac1eba861f720 Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:33:25 +0100 Subject: [PATCH 16/18] 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 28026bf6105d7251d37252ea0786dcd3e521584a Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:35:31 +0100 Subject: [PATCH 17/18] bug: whether a bot is on the server is read first in the tick, so a failure in the rest of it leaves it current --- apps/player-management-css/src/PlayerManagementPlugin.cs | 4 +++- apps/player-management-sw/src/PlayerManagementPlugin.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs index e33dc19f..642e6eea 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -276,10 +276,12 @@ private void OnTick() try { + // First, so nothing below that fails leaves it stale. + _roster.BotsPresent(Utilities.GetPlayers().Any(IsBot)); + List humans = Humans(); ObserveRoster(humans); - _roster.BotsPresent(Utilities.GetPlayers().Any(IsBot)); ReadHealth(humans); Enforce(humans); } diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs index a50d4a6d..0344c224 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -343,10 +343,12 @@ private void OnTick() try { + // First, so nothing below that fails leaves it stale. + _roster.BotsPresent(Core.PlayerManager.GetAllPlayers().Any(IsBot)); + List humans = Humans(); ObserveRoster(humans); - _roster.BotsPresent(Core.PlayerManager.GetAllPlayers().Any(IsBot)); ReadHealth(humans); Enforce(humans); } From 713eaf2923859990ab98c6a9a194264a747c24ba Mon Sep 17 00:00:00 2001 From: Flegma Date: Mon, 2 Nov 2026 16:35:59 +0100 Subject: [PATCH 18/18] 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 ) {