diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj index dc131d7f..ffe83b92 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 8474676a..92d052da 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 => { @@ -269,6 +278,9 @@ private void OnTick() try { + // First, so nothing below that fails leaves it stale. + _roster.BotsPresent(Utilities.GetPlayers().Any(IsBot)); + List humans = Humans(); ObserveRoster(humans); @@ -417,6 +429,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) @@ -429,7 +445,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) ); bool assist = - assister != null + !byBot + && assister != null && IsHuman(assister) && PlayerRoster.CountsAsAssist( assister.Slot, @@ -440,12 +457,61 @@ 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 + && !byBot + && 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: kill ? HeldWeapon(attacker!, @event.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, + ByBot: byBot + ); + _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) @@ -463,35 +529,55 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info) { CCSPlayerController? victim = @event.Userid; - if (victim == null || !IsHuman(victim)) + if (victim == null || !victim.IsValid) { return HookResult.Continue; } 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. Only a human hurting a human who is no + // enemy is team damage. 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 || IsBot(attacker)) + { + return HookResult.Continue; + } + + bool attackerIsHuman = IsHuman(attacker); + string weapon = attackerIsHuman ? HeldWeapon(attacker, @event.Weapon) : @event.Weapon; + + if (!IsHuman(victim)) { return HookResult.Continue; } + bool teammatesAreEnemies = TeammatesAreEnemies(); + + bool enemy = PlayerRoster.CountsAsKill( + attacker.Slot, + victim.Slot, + attacker.TeamNum, + victim.TeamNum, + teammatesAreEnemies + ); + _roster.Hurt( - VerifiedSteamIdOf(attacker), + attackerIsHuman ? VerifiedSteamIdOf(attacker) : null, VerifiedSteamIdOf(victim), dealt, - IsWarmup() + IsWarmup(), + new HitFacts( + weapon, + @event.Hitgroup, + Server.TickCount, + victim.Slot, + enemy, + attackerIsHuman && !enemy, + teammatesAreEnemies + ) ); } catch (Exception error) @@ -571,6 +657,119 @@ public HookResult OnPlayerTeam(EventPlayerTeam @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, HeldWeapon(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) { @@ -658,6 +857,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) @@ -672,6 +886,27 @@ private void ObserveRoster(List humans) } } + // 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)) + { + return weapon; + } + + CBasePlayerWeapon? active = player.PlayerPawn.Value?.WeaponServices?.ActiveWeapon.Value; + + return CommunityWeapons.InHand( + weapon, + active != null && active.IsValid + ? active.AttributeManager.Item.ItemDefinitionIndex + : null + ); + } + // Also kept apart from the enforcement. A pawn that cannot be read keeps // the health its last hit left. private void ReadHealth(List humans) @@ -707,19 +942,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; } } @@ -787,6 +1027,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/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj index 8263c99c..21b8785d 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 a6dcdb6a..403af532 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; @@ -334,6 +345,9 @@ 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); @@ -469,6 +483,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) @@ -481,7 +499,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) ); bool assist = - assister != null + !byBot + && assister != null && IsHuman(assister) && PlayerRoster.CountsAsAssist( assister.PlayerID, @@ -492,12 +511,61 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) @event.AssistedFlash ); + // A flash assist is counted apart, by the same rule as a kill. + bool flashAssist = + @event.AssistedFlash + && !byBot + && 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: kill ? HeldWeapon(attacker!, @event.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, + ByBot: byBot + ); + _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) @@ -515,35 +583,55 @@ public HookResult OnPlayerHurt(EventPlayerHurt @event) { IPlayer? victim = @event.UserIdPlayer; - if (victim == null || !IsHuman(victim)) + if (victim == null || !victim.IsValid) { return HookResult.Continue; } 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. Only a human hurting a human who is no + // enemy is team damage. 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 || IsBot(attacker)) + { + return HookResult.Continue; + } + + bool attackerIsHuman = IsHuman(attacker); + string weapon = attackerIsHuman ? HeldWeapon(attacker, @event.Weapon) : @event.Weapon; + + if (!IsHuman(victim)) { 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), + attackerIsHuman ? VerifiedSteamIdOf(attacker) : null, VerifiedSteamIdOf(victim), dealt, - IsWarmup() + IsWarmup(), + new HitFacts( + weapon, + (int)@event.ActualHitGroup, + Core.Engine.GlobalVars.TickCount, + victim.PlayerID, + enemy, + attackerIsHuman && !enemy, + teammatesAreEnemies + ) ); } catch (Exception error) @@ -632,6 +720,119 @@ public HookResult OnPlayerTeam(EventPlayerTeam @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, HeldWeapon(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) { @@ -719,6 +920,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) @@ -733,6 +949,27 @@ private void ObserveRoster(List humans) } } + // 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)) + { + return weapon; + } + + CBasePlayerWeapon? active = player.PlayerPawn?.WeaponServices?.ActiveWeapon.Value; + + return CommunityWeapons.InHand( + weapon, + active != null && active.IsValid + ? active.AttributeManager.Item.ItemDefinitionIndex + : null + ); + } + // Also kept apart from the enforcement. A pawn that cannot be read keeps // the health its last hit left. private void ReadHealth(List humans) @@ -768,18 +1005,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; } } @@ -894,6 +1137,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/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..c9d5a952 --- /dev/null +++ b/apps/player-management-sw/test/CommunityWeaponsTests.cs @@ -0,0 +1,157 @@ +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)); + } + + [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/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj index 456b1ddc..ae7a9012 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 0f06049a..579b5308 100644 --- a/apps/player-management-sw/test/PlayerRosterTests.cs +++ b/apps/player-management-sw/test/PlayerRosterTests.cs @@ -1909,4 +1909,987 @@ 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 + ) + { + return new HitFacts(weapon, hitGroup, tick, victim, 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)); + } + + // 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() + { + 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); + } + + // 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 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.Hurt("1", "2", 27, warmup: false, Hit(hitGroup: 1, tick: 100)); + roster.Died("2", "1", warmup: false, headshot: true); + + RosterReport withBots = roster.Report(At(1)); + Dictionary stats = StatsOf(withBots, "1"); + + 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 withoutBots = roster.Report(At(2)); + + 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. + [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, 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); + + 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); + } + + // 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, Enemy: true) + ); + 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, 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. + [Fact] + public void AHitOnATeammateIsNoHit() + { + PlayerRoster roster = new(); + roster.Observe([Human("1")], Start); + + roster.Hurt( + "1", + null, + 25, + warmup: false, + new HitFacts("ak47", 2, 100, 5, 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, 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, + 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..0f83e220 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/CommunityWeapons.cs @@ -0,0 +1,152 @@ +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)); + } + + // 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) + { + 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 3eb61211..651aa6e9 100644 --- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs +++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs @@ -5,6 +5,46 @@ 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. 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, + 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, + bool ByBot = false +); + +// 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 Enemy, + bool Team = false, + bool TeammatesAreEnemies = false +); + public sealed record RosterReport( List? Players, List Departed, @@ -35,6 +75,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 +106,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 +162,7 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At); private string? _map; private RosterReport? _final; private DateTimeOffset _finalAt; + private readonly CommunityTrades _trades = new(); private bool _roundLive; private bool _roundEnded; private bool _roundMvp; @@ -89,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) { @@ -182,6 +265,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; + } // A round the map change cut short never ends. _roundLive = false; @@ -216,13 +307,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) @@ -234,17 +327,65 @@ public void Died( { if (victim != null && _current.TryGetValue(victim, out Entry? dead)) { - dead.Deaths++; + dead.Streak = 0; + + if (facts?.ByBot != true) + { + dead.Deaths++; + + 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 ( @@ -253,25 +394,169 @@ 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 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, + 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, _botsPresent); + } + 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 (!_botsPresent && _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 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 || attacker == victim || damage <= 0) + lock (_lock) + { + _botsPresent = present; + } + } + + // 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 || 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); } } } @@ -290,14 +575,15 @@ public void FreezeTimeEnded(bool warmup) } } - // 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. + // A trade is a round's. 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) { + _trades.Clear(); _roundLive = false; _roundEnded = false; _roundMvp = false; @@ -549,6 +835,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, @@ -580,6 +891,158 @@ 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. 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 (!botsPresent && 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 (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) @@ -606,6 +1069,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(); } @@ -626,6 +1091,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(); } @@ -723,6 +1190,8 @@ private void Reconcile(DateTimeOffset now) rounds_played = entry.RoundsPlayed, rounds_won = entry.RoundsWon, mvps = entry.Mvps, + stats = StatsOf(entry), + weapons = WeaponsOf(entry), }, now )