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
)