diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index e64b81cb..dc131d7f 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 1bbc2913..8474676a 100644 --- a/apps/player-management-css/src/PlayerManagementPlugin.cs +++ b/apps/player-management-css/src/PlayerManagementPlugin.cs @@ -45,6 +45,8 @@ public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig _mutedByUs = new(); private readonly HashSet _kicked = new(); + private readonly HealthBook _health = new(); + private long _lastEnforceMs; private INetworkServerService? _networkServerService; @@ -158,6 +160,16 @@ 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: + // whoever is alive now is in it, but whoever already died in it is + // not. + _roster.FreezeTimeEnded(IsWarmup()); + + foreach (CCSPlayerController player in Humans()) + { + CountSpawn(player); + } } _loop.Start(); @@ -260,6 +272,7 @@ private void OnTick() List humans = Humans(); ObserveRoster(humans); + ReadHealth(humans); Enforce(humans); } catch (Exception error) @@ -401,6 +414,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 +425,28 @@ 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, + IsWarmup(), + assist ? VerifiedSteamIdOf(assister!) : null, + @event.Headshot + ); } catch (Exception error) { @@ -423,6 +456,208 @@ 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 = _health.Hit(victim.Slot, @event.DmgHealth, @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, + IsWarmup() + ); + } + 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.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. 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 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.PlayerPawn.Value is { IsValid: true } pawn + && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE + ) + { + _roster.Spawned(SteamIdOf(player).ToString(), player.TeamNum); + } + } + catch (Exception error) + { + Logger.LogError(error, "unable to count a spawn"); + } + } + + [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) + { + 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) + { + 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) + { + try + { + // Fired when the game starts or restarts, not at the end of a + // round anybody played. + if (@event.Message == "#SFUI_Notice_Game_Commencing") + { + _roster.GameCommencing(); + 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; + + // 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) + { + 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) @@ -437,6 +672,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() { @@ -450,6 +707,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/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 47bd7953..a6dcdb6a 100644 --- a/apps/player-management-sw/src/PlayerManagementPlugin.cs +++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs @@ -52,6 +52,8 @@ public partial class PlayerManagementPlugin : BasePlugin private readonly HashSet _mutedByUs = new(); private readonly HashSet _kicked = new(); + private readonly HealthBook _health = new(); + private EventDelegates.OnTick? _tickHandler; private EventDelegates.OnClientPutInServer? _putInServerHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; @@ -186,6 +188,16 @@ 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: + // whoever is alive now is in it, but whoever already died in it is + // not. + _roster.FreezeTimeEnded(IsWarmup()); + + foreach (IPlayer player in Humans()) + { + CountSpawn(player); + } } _loop.Start(); @@ -325,6 +337,7 @@ private void OnTick() List humans = Humans(); ObserveRoster(humans); + ReadHealth(humans); Enforce(humans); } catch (Exception error) @@ -453,6 +466,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) } IPlayer? attacker = @event.AttackerPlayer; + IPlayer? assister = @event.AssisterPlayer; + bool teammatesAreEnemies = TeammatesAreEnemies(); bool kill = attacker != null @@ -462,10 +477,28 @@ 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, + IsWarmup(), + assist ? VerifiedSteamIdOf(assister!) : null, + @event.Headshot + ); } catch (Exception error) { @@ -475,6 +508,217 @@ 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 = _health.Hit(victim.PlayerID, @event.ActualDmgHealth, @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, + IsWarmup() + ); + } + 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.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. 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 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 ( + player.IsValid + && IsHuman(player) + && player.PlayerPawn is { IsValid: true } pawn + && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE + ) + { + _roster.Spawned(SteamIdOf(player).ToString(), player.Controller.TeamNum); + } + } + catch (Exception error) + { + _logger.LogError(error, "unable to count a spawn"); + } + } + + [GameEventHandler(HookMode.Post)] + public HookResult OnPlayerTeam(EventPlayerTeam @event) + { + try + { + 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 + && !player.IsFakeClient + && SteamIdOf(player) != 0 + && @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) + { + 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) + { + 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) + { + try + { + // Fired when the game starts or restarts, not at the end of a + // round anybody played. + if (@event.Message == "#SFUI_Notice_Game_Commencing") + { + _roster.GameCommencing(); + 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; + + // 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) + { + _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) @@ -489,6 +733,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() { @@ -502,6 +768,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/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/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs index 404c96d6..0f06049a 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()); @@ -754,14 +754,14 @@ public async Task ASyncInFlightAtTheMapChangeIsNotTheMapEndReport() loop.Observe([Human("1"), Human("2")], Start); await loop.Tick(Start); - roster.Died("2", "1"); + roster.Died("2", "1", warmup: false); 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.Died("2", "1", warmup: false); roster.MapEnded(At(31)); roster.MapStarted(At(32), "de_inferno"); answer.SetResult(Panel.Ok(recorded: true)); @@ -805,15 +805,15 @@ public async Task AKillDuringTheHoldIsNotInTheMapEndReport() loop.Observe([Human("1"), Human("2")], Start); await loop.Tick(Start); - roster.Died("2", "1"); + roster.Died("2", "1", warmup: false); roster.MapEnded(At(5)); roster.MapStarted(At(6), "de_inferno"); - roster.Died("2", "1"); + roster.Died("2", "1", warmup: false); panel.Answer = () => Task.FromResult(Panel.Down()); await loop.Tick(At(6)); - roster.Died("2", "1"); + roster.Died("2", "1", warmup: false); panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true)); await loop.Tick(At(6) + SanctionSyncLoop.Interval); @@ -973,12 +973,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)); @@ -990,6 +990,708 @@ 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", 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)); + + 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, warmup: false, headshot: true); + roster.Died("2", "1", warmup: false, assister: 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", 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); + } + + [Fact] + public void DamageIsCountedForTheAttackerOnly() + { + PlayerRoster roster = new(); + roster.Observe([Human("1"), Human("2")], Start); + + 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)); + + 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, warmup: false); + + 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 spawned on and ends it on. + [Fact] + 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( + PlayerRoster.TeamT, + Teams( + ("1", PlayerRoster.TeamT), + ("2", PlayerRoster.TeamCT), + ("3", 1), + ("4", 0), + ("bot", PlayerRoster.TeamT) + ), + warmup: false + ); + Spawn( + roster, + ("1", PlayerRoster.TeamCT), + ("2", PlayerRoster.TeamCT), + ("3", PlayerRoster.TeamT) + ); + roster.FreezeTimeEnded(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); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); + roster.FreezeTimeEnded(warmup: false); + + 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); + Spawn(roster, ("1", PlayerRoster.TeamT)); + roster.FreezeTimeEnded(warmup: true); + + 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")); + } + + // 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.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); + + 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")); + } + + // 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); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); + 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")); + + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); + 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")); + } + + // 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) + ); + + Spawn(roster, ("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(); + Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)); + 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] + public void ARoundTheMapChangeCutShortIsNotCounted() + { + PlayerRoster roster = new(); + roster.MapIs("de_mirage"); + roster.Observe([Human("1")], Start); + 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)); + Spawn(roster, ("1", PlayerRoster.TeamT)); + roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: false); + roster.Mvp("1", warmup: false); + + // 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, whether the second end comes before or after the first MVP and + // whether that MVP went to a bot. + [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); + + 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); + } + + [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); + roster.Mvp("bot", warmup: false); + roster.Mvp(null, warmup: false); + + 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")); + } + + // 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) + { + 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); + 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, warmup: false); + } + + [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() { @@ -1092,6 +1794,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 = @@ -1102,6 +1810,12 @@ public void ARosterSerializesAsTheApiExpects() conn = "c2", kills = 3, deaths = 4, + assists = 1, + headshots = 2, + damage = 410, + rounds_played = 6, + rounds_won = 2, + mvps = 1, }, ], }; @@ -1113,11 +1827,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/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; + } + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs index 9404a299..3eb61211 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); @@ -69,6 +82,12 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private string? _map; private RosterReport? _final; private DateTimeOffset _finalAt; + private bool _roundLive; + 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) @@ -163,6 +182,10 @@ public void MapStarted(DateTimeOffset now, string? map) Hold(now); _mapStarted = true; _releaseAt = null; + + // A round the map change cut short never ends. + _roundLive = false; + _spawned.Clear(); } } @@ -189,9 +212,24 @@ 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. 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)) @@ -202,6 +240,196 @@ 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, bool warmup) + { + if (warmup || attacker == null || attacker == victim || damage <= 0) + { + return; + } + + lock (_lock) + { + if (_current.TryGetValue(attacker, out Entry? entry)) + { + entry.Damage += damage; + } + } + } + + // 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; + _roundEnded = false; + _roundMvp = 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; + _roundMvp = false; + } + } + + // 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 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 + // rounds are not rounds. + public void RoundEnded( + int winnerTeam, + IReadOnlyDictionary teamsBySteamId, + bool 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; + } + + _roundEnded = true; + + foreach ((string steamId, int team) in teamsBySteamId) + { + if ( + (team != TeamT && team != TeamCT) + || !_spawned.TryGetValue(steamId, out int spawnedOn) + || spawnedOn != team + || !_current.TryGetValue(steamId, out Entry? entry) + ) + { + continue; + } + + entry.RoundsPlayed++; + + if (team == winnerTeam) + { + entry.RoundsWon++; + } + } + } + + // 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) + { + return; + } + + lock (_lock) + { + if (_roundEnded && _roundMvp) + { + return; + } + + _roundMvp = _roundEnded; + + if ( + _roundLive + && steamId != null + && _current.TryGetValue(steamId, out Entry? entry) + ) + { + entry.Mvps++; } } } @@ -321,6 +549,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 +600,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 +620,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 +717,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 )