diff --git a/apps/player-management-css/src/PlayerManagement.csproj b/apps/player-management-css/src/PlayerManagement.csproj
index e64b81cb..dc131d7f 100644
--- a/apps/player-management-css/src/PlayerManagement.csproj
+++ b/apps/player-management-css/src/PlayerManagement.csproj
@@ -20,6 +20,7 @@
+
diff --git a/apps/player-management-css/src/PlayerManagementPlugin.cs b/apps/player-management-css/src/PlayerManagementPlugin.cs
index 1bbc2913..8474676a 100644
--- a/apps/player-management-css/src/PlayerManagementPlugin.cs
+++ b/apps/player-management-css/src/PlayerManagementPlugin.cs
@@ -45,6 +45,8 @@ public partial class PlayerManagementPlugin : BasePlugin, IPluginConfig _mutedByUs = new();
private readonly HashSet _kicked = new();
+ private readonly HealthBook _health = new();
+
private long _lastEnforceMs;
private INetworkServerService? _networkServerService;
@@ -158,6 +160,16 @@ public override void Load(bool hotReload)
{
Logger.LogDebug(error, "unable to read the map on load");
}
+
+ // Nor has it seen the round under way go live, or anyone in it spawn:
+ // whoever is alive now is in it, but whoever already died in it is
+ // not.
+ _roster.FreezeTimeEnded(IsWarmup());
+
+ foreach (CCSPlayerController player in Humans())
+ {
+ CountSpawn(player);
+ }
}
_loop.Start();
@@ -260,6 +272,7 @@ private void OnTick()
List humans = Humans();
ObserveRoster(humans);
+ ReadHealth(humans);
Enforce(humans);
}
catch (Exception error)
@@ -401,6 +414,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
}
CCSPlayerController? attacker = @event.Attacker;
+ CCSPlayerController? assister = @event.Assister;
+ bool teammatesAreEnemies = TeammatesAreEnemies();
bool kill =
attacker != null
@@ -410,10 +425,28 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
victim.Slot,
attacker.TeamNum,
victim.TeamNum,
- TeammatesAreEnemies()
+ teammatesAreEnemies
);
- _roster.Died(VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null);
+ bool assist =
+ assister != null
+ && IsHuman(assister)
+ && PlayerRoster.CountsAsAssist(
+ assister.Slot,
+ victim.Slot,
+ assister.TeamNum,
+ victim.TeamNum,
+ teammatesAreEnemies,
+ @event.Assistedflash
+ );
+
+ _roster.Died(
+ VerifiedSteamIdOf(victim),
+ kill ? VerifiedSteamIdOf(attacker!) : null,
+ IsWarmup(),
+ assist ? VerifiedSteamIdOf(assister!) : null,
+ @event.Headshot
+ );
}
catch (Exception error)
{
@@ -423,6 +456,208 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
return HookResult.Continue;
}
+ [GameEventHandler]
+ public HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info)
+ {
+ try
+ {
+ CCSPlayerController? victim = @event.Userid;
+
+ if (victim == null || !IsHuman(victim))
+ {
+ return HookResult.Continue;
+ }
+
+ int dealt = _health.Hit(victim.Slot, @event.DmgHealth, @event.Health);
+
+ CCSPlayerController? attacker = @event.Attacker;
+
+ if (
+ attacker == null
+ || !IsHuman(attacker)
+ || !PlayerRoster.CountsAsKill(
+ attacker.Slot,
+ victim.Slot,
+ attacker.TeamNum,
+ victim.TeamNum,
+ TeammatesAreEnemies()
+ )
+ )
+ {
+ return HookResult.Continue;
+ }
+
+ _roster.Hurt(
+ VerifiedSteamIdOf(attacker),
+ VerifiedSteamIdOf(victim),
+ dealt,
+ IsWarmup()
+ );
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to count damage");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler]
+ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info)
+ {
+ try
+ {
+ CCSPlayerController? player = @event.Userid;
+
+ if (player != null)
+ {
+ _health.Spawned(player.Slot);
+
+ // Joining a side fires a spawn too, for a player who stays dead
+ // until the next round, so whether they are alive is only read
+ // once the spawn is over. A spawn in the frame the round ends
+ // is read after it, and so goes to the next round.
+ Server.NextFrame(() => CountSpawn(player));
+ }
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to reset a player's health or count their spawn");
+ }
+
+ return HookResult.Continue;
+ }
+
+ // Read off the player's own pawn rather than a flag the controller keeps
+ // for it. A spawn is kept under the id the player claims, so one who spawns
+ // before Steam verifies them is in the round; only a verified id is
+ // credited for it at the round's end.
+ private void CountSpawn(CCSPlayerController player)
+ {
+ try
+ {
+ if (
+ IsHuman(player)
+ && player.PlayerPawn.Value is { IsValid: true } pawn
+ && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE
+ )
+ {
+ _roster.Spawned(SteamIdOf(player).ToString(), player.TeamNum);
+ }
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to count a spawn");
+ }
+ }
+
+ [GameEventHandler]
+ public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info)
+ {
+ try
+ {
+ CCSPlayerController? player = @event.Userid;
+
+ if (player != null && IsHuman(player) && @event.Team != @event.Oldteam)
+ {
+ _roster.LeftSide(SteamIdOf(player).ToString());
+ }
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to forget a spawn on a team change");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler]
+ public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info)
+ {
+ try
+ {
+ _roster.RoundStarted();
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to start a round");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler]
+ public HookResult OnRoundFreezeEnd(EventRoundFreezeEnd @event, GameEventInfo info)
+ {
+ try
+ {
+ _roster.FreezeTimeEnded(IsWarmup());
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to start counting a round");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler]
+ public HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info)
+ {
+ try
+ {
+ // Fired when the game starts or restarts, not at the end of a
+ // round anybody played.
+ if (@event.Message == "#SFUI_Notice_Game_Commencing")
+ {
+ _roster.GameCommencing();
+ return HookResult.Continue;
+ }
+
+ Dictionary teams = new();
+
+ foreach (CCSPlayerController player in Humans())
+ {
+ string? steamId = VerifiedSteamIdOf(player);
+
+ if (steamId != null)
+ {
+ teams[steamId] = player.TeamNum;
+ }
+ }
+
+ _roster.RoundEnded(@event.Winner, teams, IsWarmup());
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to count a round");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler]
+ public HookResult OnRoundMvp(EventRoundMvp @event, GameEventInfo info)
+ {
+ try
+ {
+ CCSPlayerController? player = @event.Userid;
+
+ // A bot's MVP, or one whose player has gone, is still the round's
+ // MVP, so it reaches the roster without a player to credit.
+ _roster.Mvp(
+ player != null && IsHuman(player) ? VerifiedSteamIdOf(player) : null,
+ IsWarmup()
+ );
+ }
+ catch (Exception error)
+ {
+ Logger.LogError(error, "unable to count an MVP");
+ }
+
+ return HookResult.Continue;
+ }
+
// Kept apart from the enforcement that follows it on the tick, which must
// run whatever happens to the roster.
private void ObserveRoster(List humans)
@@ -437,6 +672,28 @@ private void ObserveRoster(List humans)
}
}
+ // Also kept apart from the enforcement. A pawn that cannot be read keeps
+ // the health its last hit left.
+ private void ReadHealth(List humans)
+ {
+ foreach (CCSPlayerController player in humans)
+ {
+ try
+ {
+ CCSPlayerPawn? pawn = player.PlayerPawn.Value;
+
+ if (pawn != null && pawn.IsValid)
+ {
+ _health.Seen(player.Slot, pawn.Health);
+ }
+ }
+ catch (Exception error)
+ {
+ Logger.LogDebug(error, "unable to read a player's health");
+ }
+ }
+ }
+
// Unreadable is taken as teams as usual, where a team kill earns nothing.
private static bool TeammatesAreEnemies()
{
@@ -450,6 +707,23 @@ private static bool TeammatesAreEnemies()
}
}
+ // Unreadable is taken as live, as a server without warmup is.
+ private static bool IsWarmup()
+ {
+ try
+ {
+ return Utilities
+ .FindAllEntitiesByDesignerName("cs_gamerules")
+ .FirstOrDefault()
+ ?.GameRules?.WarmupPeriod
+ ?? false;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
private HookResult OnChat(CCSPlayerController? player, CommandInfo info)
{
if (player == null || !IsHuman(player))
diff --git a/apps/player-management-sw/src/PlayerManagement.csproj b/apps/player-management-sw/src/PlayerManagement.csproj
index ce23c9ff..8263c99c 100644
--- a/apps/player-management-sw/src/PlayerManagement.csproj
+++ b/apps/player-management-sw/src/PlayerManagement.csproj
@@ -27,6 +27,7 @@
+
diff --git a/apps/player-management-sw/src/PlayerManagementPlugin.cs b/apps/player-management-sw/src/PlayerManagementPlugin.cs
index 47bd7953..a6dcdb6a 100644
--- a/apps/player-management-sw/src/PlayerManagementPlugin.cs
+++ b/apps/player-management-sw/src/PlayerManagementPlugin.cs
@@ -52,6 +52,8 @@ public partial class PlayerManagementPlugin : BasePlugin
private readonly HashSet _mutedByUs = new();
private readonly HashSet _kicked = new();
+ private readonly HealthBook _health = new();
+
private EventDelegates.OnTick? _tickHandler;
private EventDelegates.OnClientPutInServer? _putInServerHandler;
private EventDelegates.OnClientSteamAuthorize? _authorizeHandler;
@@ -186,6 +188,16 @@ public override void Load(bool hotReload)
{
_logger.LogDebug(error, "unable to read the map on load");
}
+
+ // Nor has it seen the round under way go live, or anyone in it spawn:
+ // whoever is alive now is in it, but whoever already died in it is
+ // not.
+ _roster.FreezeTimeEnded(IsWarmup());
+
+ foreach (IPlayer player in Humans())
+ {
+ CountSpawn(player);
+ }
}
_loop.Start();
@@ -325,6 +337,7 @@ private void OnTick()
List humans = Humans();
ObserveRoster(humans);
+ ReadHealth(humans);
Enforce(humans);
}
catch (Exception error)
@@ -453,6 +466,8 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event)
}
IPlayer? attacker = @event.AttackerPlayer;
+ IPlayer? assister = @event.AssisterPlayer;
+ bool teammatesAreEnemies = TeammatesAreEnemies();
bool kill =
attacker != null
@@ -462,10 +477,28 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event)
victim.PlayerID,
attacker.Controller.TeamNum,
victim.Controller.TeamNum,
- TeammatesAreEnemies()
+ teammatesAreEnemies
);
- _roster.Died(VerifiedSteamIdOf(victim), kill ? VerifiedSteamIdOf(attacker!) : null);
+ bool assist =
+ assister != null
+ && IsHuman(assister)
+ && PlayerRoster.CountsAsAssist(
+ assister.PlayerID,
+ victim.PlayerID,
+ assister.Controller.TeamNum,
+ victim.Controller.TeamNum,
+ teammatesAreEnemies,
+ @event.AssistedFlash
+ );
+
+ _roster.Died(
+ VerifiedSteamIdOf(victim),
+ kill ? VerifiedSteamIdOf(attacker!) : null,
+ IsWarmup(),
+ assist ? VerifiedSteamIdOf(assister!) : null,
+ @event.Headshot
+ );
}
catch (Exception error)
{
@@ -475,6 +508,217 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event)
return HookResult.Continue;
}
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnPlayerHurt(EventPlayerHurt @event)
+ {
+ try
+ {
+ IPlayer? victim = @event.UserIdPlayer;
+
+ if (victim == null || !IsHuman(victim))
+ {
+ return HookResult.Continue;
+ }
+
+ int dealt = _health.Hit(victim.PlayerID, @event.ActualDmgHealth, @event.ActualHealth);
+
+ IPlayer? attacker = @event.AttackerPlayer;
+
+ if (
+ attacker == null
+ || !IsHuman(attacker)
+ || !PlayerRoster.CountsAsKill(
+ attacker.PlayerID,
+ victim.PlayerID,
+ attacker.Controller.TeamNum,
+ victim.Controller.TeamNum,
+ TeammatesAreEnemies()
+ )
+ )
+ {
+ return HookResult.Continue;
+ }
+
+ _roster.Hurt(
+ VerifiedSteamIdOf(attacker),
+ VerifiedSteamIdOf(victim),
+ dealt,
+ IsWarmup()
+ );
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to count damage");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnPlayerSpawn(EventPlayerSpawn @event)
+ {
+ try
+ {
+ IPlayer? player = @event.UserIdPlayer;
+
+ if (player != null)
+ {
+ _health.Spawned(player.PlayerID);
+
+ // Joining a side fires a spawn too, for a player who stays dead
+ // until the next round, so whether they are alive is only read
+ // once the spawn is over. A spawn in the tick the round ends
+ // is read after it, and so goes to the next round.
+ Core.Scheduler.NextTick(() => CountSpawn(player));
+ }
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to reset a player's health or count their spawn");
+ }
+
+ return HookResult.Continue;
+ }
+
+ // Read off the player's own pawn: a dead player's current pawn is the one
+ // they watch the round through. A spawn is kept under the id the player
+ // claims, so one who spawns before Steam verifies them is in the round;
+ // only a verified id is credited for it at the round's end.
+ private void CountSpawn(IPlayer player)
+ {
+ try
+ {
+ if (
+ player.IsValid
+ && IsHuman(player)
+ && player.PlayerPawn is { IsValid: true } pawn
+ && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE
+ )
+ {
+ _roster.Spawned(SteamIdOf(player).ToString(), player.Controller.TeamNum);
+ }
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to count a spawn");
+ }
+ }
+
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnPlayerTeam(EventPlayerTeam @event)
+ {
+ try
+ {
+ IPlayer? player = @event.UserIdPlayer;
+
+ // Not IsHuman: a player leaving the server is already no longer
+ // connected when their team goes, and leaving it leaves the side.
+ if (
+ player != null
+ && player.IsValid
+ && !player.IsFakeClient
+ && SteamIdOf(player) != 0
+ && @event.Team != @event.OldTeam
+ )
+ {
+ _roster.LeftSide(SteamIdOf(player).ToString());
+ }
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to forget a spawn on a team change");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnRoundStart(EventRoundStart @event)
+ {
+ try
+ {
+ _roster.RoundStarted();
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to start a round");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnRoundFreezeEnd(EventRoundFreezeEnd @event)
+ {
+ try
+ {
+ _roster.FreezeTimeEnded(IsWarmup());
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to start counting a round");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnRoundEnd(EventRoundEnd @event)
+ {
+ try
+ {
+ // Fired when the game starts or restarts, not at the end of a
+ // round anybody played.
+ if (@event.Message == "#SFUI_Notice_Game_Commencing")
+ {
+ _roster.GameCommencing();
+ return HookResult.Continue;
+ }
+
+ Dictionary teams = new();
+
+ foreach (IPlayer player in Humans())
+ {
+ string? steamId = VerifiedSteamIdOf(player);
+
+ if (steamId != null)
+ {
+ teams[steamId] = player.Controller.TeamNum;
+ }
+ }
+
+ _roster.RoundEnded(@event.Winner, teams, IsWarmup());
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to count a round");
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler(HookMode.Post)]
+ public HookResult OnRoundMvp(EventRoundMvp @event)
+ {
+ try
+ {
+ IPlayer? player = @event.UserIdPlayer;
+
+ // A bot's MVP, or one whose player has gone, is still the round's
+ // MVP, so it reaches the roster without a player to credit.
+ _roster.Mvp(
+ player != null && IsHuman(player) ? VerifiedSteamIdOf(player) : null,
+ IsWarmup()
+ );
+ }
+ catch (Exception error)
+ {
+ _logger.LogError(error, "unable to count an MVP");
+ }
+
+ return HookResult.Continue;
+ }
+
// Kept apart from the enforcement that follows it on the tick, which must
// run whatever happens to the roster.
private void ObserveRoster(List humans)
@@ -489,6 +733,28 @@ private void ObserveRoster(List humans)
}
}
+ // Also kept apart from the enforcement. A pawn that cannot be read keeps
+ // the health its last hit left.
+ private void ReadHealth(List humans)
+ {
+ foreach (IPlayer player in humans)
+ {
+ try
+ {
+ CCSPlayerPawn? pawn = player.PlayerPawn;
+
+ if (pawn != null && pawn.IsValid)
+ {
+ _health.Seen(player.PlayerID, pawn.Health);
+ }
+ }
+ catch (Exception error)
+ {
+ _logger.LogDebug(error, "unable to read a player's health");
+ }
+ }
+ }
+
// Unreadable is taken as teams as usual, where a team kill earns nothing.
private bool TeammatesAreEnemies()
{
@@ -502,6 +768,22 @@ private bool TeammatesAreEnemies()
}
}
+ // Unreadable is taken as live, as a server without warmup is.
+ private bool IsWarmup()
+ {
+ try
+ {
+ return Core.EntitySystem.GetAllEntitiesByDesignerName("cs_gamerules")
+ .FirstOrDefault()
+ ?.GameRules?.WarmupPeriod
+ ?? false;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
// Still called while the server hibernates, when OnTick is not, which is
// how the roster tells an empty server from one loading a map, and what
// lets an empty server reload its map.
diff --git a/apps/player-management-sw/test/FiveStack.Tests.csproj b/apps/player-management-sw/test/FiveStack.Tests.csproj
index 0849a281..456b1ddc 100644
--- a/apps/player-management-sw/test/FiveStack.Tests.csproj
+++ b/apps/player-management-sw/test/FiveStack.Tests.csproj
@@ -20,6 +20,7 @@
+
diff --git a/apps/player-management-sw/test/HealthBookTests.cs b/apps/player-management-sw/test/HealthBookTests.cs
new file mode 100644
index 00000000..4d67c6ff
--- /dev/null
+++ b/apps/player-management-sw/test/HealthBookTests.cs
@@ -0,0 +1,60 @@
+using FiveStack.Utilities;
+using Xunit;
+
+public class HealthBookTests
+{
+ [Fact]
+ public void ALethalHitIsCappedAtWhatTheLastHitLeft()
+ {
+ HealthBook health = new();
+
+ Assert.Equal(70, health.Hit(1, 70, 30));
+ Assert.Equal(30, health.Hit(1, 448, 0));
+ }
+
+ // Deathmatch heals a player on a kill and fires nothing for it.
+ [Fact]
+ public void AHealWithoutAnEventIsReadOffThePawn()
+ {
+ HealthBook health = new();
+ health.Hit(1, 70, 30);
+
+ health.Seen(1, 100);
+
+ Assert.Equal(100, health.Hit(1, 448, 0));
+ }
+
+ [Fact]
+ public void ADeadPawnKeepsWhatItsLastHitLeft()
+ {
+ HealthBook health = new();
+ health.Hit(1, 60, 40);
+
+ health.Seen(1, 0);
+
+ Assert.Equal(40, health.Hit(1, 448, 0));
+ }
+
+ [Fact]
+ public void ASpawnStartsAtFullHealth()
+ {
+ HealthBook health = new();
+ health.Hit(1, 60, 40);
+
+ health.Spawned(1);
+
+ Assert.Equal(PlayerRoster.FullHealth, health.Hit(1, 448, 0));
+ }
+
+ [Fact]
+ public void EachSlotKeepsItsOwnHealth()
+ {
+ HealthBook health = new();
+ health.Hit(1, 60, 40);
+ health.Seen(2, 90);
+
+ Assert.Equal(40, health.Hit(1, 448, 0));
+ Assert.Equal(90, health.Hit(2, 448, 0));
+ Assert.Equal(PlayerRoster.FullHealth, health.Hit(3, 448, 0));
+ }
+}
diff --git a/apps/player-management-sw/test/PlayerRosterTests.cs b/apps/player-management-sw/test/PlayerRosterTests.cs
index 404c96d6..0f06049a 100644
--- a/apps/player-management-sw/test/PlayerRosterTests.cs
+++ b/apps/player-management-sw/test/PlayerRosterTests.cs
@@ -289,7 +289,7 @@ public async Task ASilentServerIsEmptyOnceTheTicksStop()
{
var (loop, roster, _, panel) = Loop();
loop.Observe([Human("1")], Start);
- roster.Died(null, "1");
+ roster.Died(null, "1", warmup: false);
await loop.Tick(Start);
await loop.Tick(Start + PlayerRoster.Silence);
@@ -534,10 +534,10 @@ public async Task CountersBelongToTheConnectionAndStartOverOnRejoin()
{
var (loop, roster, _, panel) = Loop();
loop.Observe([Human("1"), Human("2")], Start);
- roster.Died("2", "1");
- roster.Died("2", "1");
- roster.Died("1", null);
- roster.Died(null, "bot");
+ roster.Died("2", "1", warmup: false);
+ roster.Died("2", "1", warmup: false);
+ roster.Died("1", null, warmup: false);
+ roster.Died(null, "bot", warmup: false);
await loop.Tick(Start);
Assert.Equal(
@@ -550,7 +550,7 @@ public async Task CountersBelongToTheConnectionAndStartOverOnRejoin()
loop.Observe([Human("2")], At(1));
loop.Observe([Human("1"), Human("2")], At(2));
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
await loop.Tick(At(1) + PlayerRoster.Debounce);
Assert.Equal(1, panel.Players(1)![0].Kills);
@@ -585,7 +585,7 @@ public async Task DeparturesAreKeptUntilASyncSucceeds()
loop.Observe([Human("1")], Start);
await loop.Tick(Start);
- roster.Died(null, "1");
+ roster.Died(null, "1", warmup: false);
loop.Observe([], At(1));
panel.Answer = () => Task.FromResult(Panel.Down());
@@ -679,8 +679,8 @@ public async Task AMapsLastKillsAreReportedOnThatMap()
loop.Observe([Human("1"), Human("2")], Start);
await loop.Tick(Start);
- roster.Died("2", "1");
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
+ roster.Died("2", "1", warmup: false);
roster.MapEnded(At(5));
roster.MapStarted(At(6), "de_inferno");
await loop.Tick(At(6));
@@ -700,7 +700,7 @@ public async Task AMapsLastKillsAreReportedOnThatMap()
Assert.Equal("de_inferno", panel.Map(2));
loop.Observe([Human("1"), Human("2")], At(6) + SanctionSyncLoop.Interval * 2);
- roster.Died("1", "2");
+ roster.Died("1", "2", warmup: false);
await loop.Tick(At(6) + SanctionSyncLoop.Interval * 2);
Assert.Equal("de_inferno", panel.Map(3));
@@ -721,7 +721,7 @@ public async Task AMapEndReportThePanelMissedIsSentAgain()
loop.Observe([Human("1")], Start);
await loop.Tick(Start);
- roster.Died(null, "1");
+ roster.Died(null, "1", warmup: false);
roster.MapEnded(At(5));
roster.MapStarted(At(6), "de_inferno");
panel.Answer = () => Task.FromResult(Panel.Down());
@@ -754,14 +754,14 @@ public async Task ASyncInFlightAtTheMapChangeIsNotTheMapEndReport()
loop.Observe([Human("1"), Human("2")], Start);
await loop.Tick(Start);
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
TaskCompletionSource answer = new();
panel.Answer = () => answer.Task;
loop.Observe([Human("1"), Human("2")], Start + SanctionSyncLoop.Interval);
Task inFlight = loop.Tick(Start + SanctionSyncLoop.Interval);
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
roster.MapEnded(At(31));
roster.MapStarted(At(32), "de_inferno");
answer.SetResult(Panel.Ok(recorded: true));
@@ -805,15 +805,15 @@ public async Task AKillDuringTheHoldIsNotInTheMapEndReport()
loop.Observe([Human("1"), Human("2")], Start);
await loop.Tick(Start);
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
roster.MapEnded(At(5));
roster.MapStarted(At(6), "de_inferno");
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
panel.Answer = () => Task.FromResult(Panel.Down());
await loop.Tick(At(6));
- roster.Died("2", "1");
+ roster.Died("2", "1", warmup: false);
panel.Answer = () => Task.FromResult(Panel.Ok(recorded: true));
await loop.Tick(At(6) + SanctionSyncLoop.Interval);
@@ -973,12 +973,12 @@ public void EachConnectionDepartsOnItsOwn()
{
PlayerRoster roster = new();
roster.Observe([Human("1")], Start);
- roster.Died(null, "1");
+ roster.Died(null, "1", warmup: false);
roster.Observe([], At(1));
roster.Observe([Human("1")], At(2));
- roster.Died(null, "1");
- roster.Died(null, "1");
- roster.Died("1", null);
+ roster.Died(null, "1", warmup: false);
+ roster.Died(null, "1", warmup: false);
+ roster.Died("1", null, warmup: false);
roster.Observe([], At(3));
RosterReport report = roster.Report(At(4));
@@ -990,6 +990,708 @@ public void EachConnectionDepartsOnItsOwn()
Assert.NotEqual(report.Departed[0].conn, report.Departed[1].conn);
}
+ private sealed record Counters(
+ int Kills,
+ int Deaths,
+ int Assists,
+ int Headshots,
+ int Damage,
+ int RoundsPlayed,
+ int RoundsWon,
+ int Mvps
+ );
+
+ private static Counters CountersOf(RosterPlayer player)
+ {
+ return new Counters(
+ player.kills,
+ player.deaths,
+ player.assists,
+ player.headshots,
+ player.damage,
+ player.rounds_played,
+ player.rounds_won,
+ player.mvps
+ );
+ }
+
+ private static Counters CountersOf(DepartedPlayer player)
+ {
+ return new Counters(
+ player.kills,
+ player.deaths,
+ player.assists,
+ player.headshots,
+ player.damage,
+ player.rounds_played,
+ player.rounds_won,
+ player.mvps
+ );
+ }
+
+ private static Counters CountersOf(RosterReport report, string steamId)
+ {
+ return CountersOf(report.Players!.Single(player => player.steam_id == steamId));
+ }
+
+ private static (int Played, int Won) Rounds(RosterReport report, string steamId)
+ {
+ Counters counters = CountersOf(report, steamId);
+
+ return (counters.RoundsPlayed, counters.RoundsWon);
+ }
+
+ private static Dictionary Teams(params (string SteamId, int Team)[] teams)
+ {
+ return teams.ToDictionary(team => team.SteamId, team => team.Team);
+ }
+
+ [Fact]
+ public void AssistsAndHeadshotsGoToRosterPlayersOnly()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2"), Human("3")], Start);
+
+ roster.Died("2", "1", warmup: false, assister: "3", headshot: true);
+ roster.Died("2", "1", warmup: false, assister: "bot", headshot: false);
+ roster.Died("3", "bot", warmup: false, assister: "1", headshot: true);
+ roster.Died("3", null, warmup: false, assister: null, headshot: true);
+
+ RosterReport report = roster.Report(At(1));
+
+ Assert.Equal(new Counters(2, 0, 1, 1, 0, 0, 0, 0), CountersOf(report, "1"));
+ Assert.Equal(new Counters(0, 2, 0, 0, 0, 0, 0, 0), CountersOf(report, "2"));
+ Assert.Equal(new Counters(0, 2, 1, 0, 0, 0, 0, 0), CountersOf(report, "3"));
+ }
+
+ // The hooks pass no killer for a kill that does not count, so a team kill's
+ // headshot is not one.
+ [Fact]
+ public void AHeadshotCountsOnlyWithACountedKill()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ bool teamKill = PlayerRoster.CountsAsKill(
+ 1,
+ 2,
+ PlayerRoster.TeamT,
+ PlayerRoster.TeamT,
+ false
+ );
+
+ roster.Died("2", teamKill ? "1" : null, warmup: false, headshot: true);
+ roster.Died("2", "1", warmup: false, assister: null, headshot: true);
+
+ Assert.Equal(1, CountersOf(roster.Report(At(1)), "1").Headshots);
+ }
+
+ [Theory]
+ [InlineData(1, 2, 2, 3, false, false, true)]
+ [InlineData(1, 2, 2, 3, false, true, false)]
+ [InlineData(1, 2, 2, 2, false, false, false)]
+ [InlineData(1, 2, 2, 2, true, false, true)]
+ [InlineData(1, 1, 2, 2, true, false, false)]
+ public void AnAssistIsDamageToAnEnemyNotAFlash(
+ int assister,
+ int victim,
+ int assisterTeam,
+ int victimTeam,
+ bool teammatesAreEnemies,
+ bool flash,
+ bool expected
+ )
+ {
+ Assert.Equal(
+ expected,
+ PlayerRoster.CountsAsAssist(
+ assister,
+ victim,
+ assisterTeam,
+ victimTeam,
+ teammatesAreEnemies,
+ flash
+ )
+ );
+ }
+
+ [Fact]
+ public void AFlashAssistEarnsNothing()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2"), Human("3")], Start);
+ bool flash = PlayerRoster.CountsAsAssist(
+ 3,
+ 2,
+ PlayerRoster.TeamCT,
+ PlayerRoster.TeamT,
+ false,
+ true
+ );
+ bool damage = PlayerRoster.CountsAsAssist(
+ 3,
+ 2,
+ PlayerRoster.TeamCT,
+ PlayerRoster.TeamT,
+ false,
+ false
+ );
+
+ roster.Died("2", "1", warmup: false, assister: flash ? "3" : null);
+ roster.Died("2", "1", warmup: false, assister: damage ? "3" : null);
+
+ Assert.Equal(1, CountersOf(roster.Report(At(1)), "3").Assists);
+ }
+
+ [Fact]
+ public void DamageIsCountedForTheAttackerOnly()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+
+ roster.Hurt("1", "2", 27, warmup: false);
+ roster.Hurt("1", "2", 73, warmup: false);
+ roster.Hurt("1", "1", 50, warmup: false);
+ roster.Hurt("1", "2", 0, warmup: false);
+ roster.Hurt("1", "2", -5, warmup: false);
+ roster.Hurt("bot", "2", 40, warmup: false);
+ roster.Hurt(null, "2", 40, warmup: false);
+ roster.Hurt("1", null, 10, warmup: false);
+
+ RosterReport report = roster.Report(At(1));
+
+ Assert.Equal(110, CountersOf(report, "1").Damage);
+ Assert.Equal(0, CountersOf(report, "2").Damage);
+ }
+
+ // Damage goes by the kill rule: a teammate's or one's own health is no
+ // damage, unless teammates are enemies.
+ [Theory]
+ [InlineData(1, 2, 2, 3, false, 30)]
+ [InlineData(1, 2, 2, 2, false, 0)]
+ [InlineData(1, 1, 2, 2, false, 0)]
+ [InlineData(1, 2, 2, 2, true, 30)]
+ public void TeamDamageEarnsNothing(
+ int attacker,
+ int victim,
+ int attackerTeam,
+ int victimTeam,
+ bool teammatesAreEnemies,
+ int expected
+ )
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ bool enemy = PlayerRoster.CountsAsKill(
+ attacker,
+ victim,
+ attackerTeam,
+ victimTeam,
+ teammatesAreEnemies
+ );
+
+ roster.Hurt(enemy ? "1" : null, attacker == victim ? "1" : "2", 30, warmup: false);
+
+ Assert.Equal(expected, CountersOf(roster.Report(At(1)), "1").Damage);
+ }
+
+ [Theory]
+ [InlineData(27, 73, null, 27)]
+ [InlineData(27, 73, 40, 27)]
+ [InlineData(448, 0, null, 100)]
+ [InlineData(448, 0, 31, 31)]
+ [InlineData(20, 0, 31, 20)]
+ [InlineData(448, 0, 0, 0)]
+ [InlineData(-3, 50, null, 0)]
+ public void ALethalHitIsCappedAtTheHealthThatWasLeft(
+ int damage,
+ int healthAfter,
+ int? healthBefore,
+ int expected
+ )
+ {
+ Assert.Equal(expected, PlayerRoster.Dealt(damage, healthAfter, healthBefore));
+ }
+
+ // Player 1 switches sides between the rounds and wins both: a round goes to
+ // the side a player spawned on and ends it on.
+ [Fact]
+ public void ARoundIsPlayedByEveryoneWhoSpawnedOnASideAndWonByTheWinners()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2"), Human("3"), Human("4")], Start);
+ Spawn(
+ roster,
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamCT),
+ ("bot", PlayerRoster.TeamT)
+ );
+ roster.FreezeTimeEnded(warmup: false);
+
+ roster.RoundEnded(
+ PlayerRoster.TeamT,
+ Teams(
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamCT),
+ ("3", 1),
+ ("4", 0),
+ ("bot", PlayerRoster.TeamT)
+ ),
+ warmup: false
+ );
+ Spawn(
+ roster,
+ ("1", PlayerRoster.TeamCT),
+ ("2", PlayerRoster.TeamCT),
+ ("3", PlayerRoster.TeamT)
+ );
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(
+ PlayerRoster.TeamCT,
+ Teams(
+ ("1", PlayerRoster.TeamCT),
+ ("2", PlayerRoster.TeamCT),
+ ("3", PlayerRoster.TeamT)
+ ),
+ warmup: false
+ );
+
+ RosterReport report = roster.Report(At(1));
+
+ Assert.Equal((2, 2), Rounds(report, "1"));
+ Assert.Equal((2, 1), Rounds(report, "2"));
+ Assert.Equal((1, 0), Rounds(report, "3"));
+ Assert.Equal((0, 0), Rounds(report, "4"));
+ }
+
+ // A draw has no winners, and is still a round everybody on a side played.
+ [Fact]
+ public void ADrawIsPlayedAndWonByNobody()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT));
+ roster.FreezeTimeEnded(warmup: false);
+
+ roster.RoundEnded(
+ 1,
+ Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)),
+ warmup: false
+ );
+
+ RosterReport report = roster.Report(At(1));
+
+ Assert.Equal((1, 0), Rounds(report, "1"));
+ Assert.Equal((1, 0), Rounds(report, "2"));
+ }
+
+ [Fact]
+ public void WarmupRoundsAreNotCounted()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1")], Start);
+ Spawn(roster, ("1", PlayerRoster.TeamT));
+ roster.FreezeTimeEnded(warmup: true);
+
+ roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true);
+
+ Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(1)), "1"));
+ }
+
+ // Warmup ends without a round of its own: what players do in it is
+ // dropped, and what they do after it counts.
+ [Fact]
+ public void NothingDoneInWarmupIsCounted()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2"), Human("3")], Start);
+ roster.FreezeTimeEnded(warmup: true);
+
+ roster.Died("2", "1", warmup: true, assister: "3", headshot: true);
+ roster.Hurt("1", "2", 100, warmup: true);
+ roster.Mvp("1", warmup: true);
+ roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: true);
+
+ roster.FreezeTimeEnded(warmup: false);
+ roster.Died("2", "1", warmup: false, assister: "3", headshot: true);
+ roster.Hurt("1", "2", 100, warmup: false);
+ roster.Mvp("1", warmup: false);
+
+ RosterReport report = roster.Report(At(1));
+
+ Assert.Equal(new Counters(1, 0, 0, 1, 100, 0, 0, 1), CountersOf(report, "1"));
+ Assert.Equal(new Counters(0, 1, 0, 0, 0, 0, 0, 0), CountersOf(report, "2"));
+ Assert.Equal(new Counters(0, 0, 1, 0, 0, 0, 0, 0), CountersOf(report, "3"));
+ }
+
+ // Ending warmup ends its round as a win for whoever is alive, and the warmup
+ // flag can already be off by then. Only the round after it counts.
+ [Fact]
+ public void EndingWarmupHandsOutNoRound()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT));
+ roster.FreezeTimeEnded(warmup: true);
+
+ roster.RoundEnded(
+ PlayerRoster.TeamT,
+ Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)),
+ warmup: false
+ );
+ roster.Mvp("1", warmup: false);
+
+ Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(roster.Report(At(1)), "1"));
+
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT));
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(
+ PlayerRoster.TeamT,
+ Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT)),
+ warmup: false
+ );
+ roster.Mvp("1", warmup: false);
+
+ RosterReport report = roster.Report(At(2));
+
+ Assert.Equal((1, 1), Rounds(report, "1"));
+ Assert.Equal(1, CountersOf(report, "1").Mvps);
+ Assert.Equal((1, 0), Rounds(report, "2"));
+ }
+
+ // An admin or a plugin can end a round again before the next one starts,
+ // and the next round only counts once its own freeze time has ended.
+ [Fact]
+ public void ARoundCountsOnceAndTheNextOneOnlyOnceItGoesLive()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ IReadOnlyDictionary teams = Teams(
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamCT)
+ );
+
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT));
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+ roster.Mvp("1", warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false);
+
+ Assert.Equal((1, 1), Rounds(roster.Report(At(1)), "1"));
+ Assert.Equal((1, 0), Rounds(roster.Report(At(1)), "2"));
+
+ roster.RoundStarted();
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamCT));
+ roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false);
+ roster.Mvp("2", warmup: false);
+
+ RosterReport report = roster.Report(At(2));
+ Assert.Equal((1, 0), Rounds(report, "2"));
+ Assert.Equal(0, CountersOf(report, "2").Mvps);
+ Assert.Equal(1, CountersOf(report, "1").Mvps);
+ }
+
+ // A changelevel cuts the round under way short, and the new map's first
+ // round has not gone live until its freeze time ends.
+ [Fact]
+ public void ARoundTheMapChangeCutShortIsNotCounted()
+ {
+ PlayerRoster roster = new();
+ roster.MapIs("de_mirage");
+ roster.Observe([Human("1")], Start);
+ roster.FreezeTimeEnded(warmup: false);
+
+ roster.MapEnded(At(1));
+ roster.Delivered(roster.Report(At(1)), true);
+ roster.MapStarted(At(2), "de_inferno");
+ roster.Observe([Human("1")], At(3));
+ Spawn(roster, ("1", PlayerRoster.TeamT));
+ roster.RoundEnded(PlayerRoster.TeamT, Teams(("1", PlayerRoster.TeamT)), warmup: false);
+ roster.Mvp("1", warmup: false);
+
+ // The old map's report is delivered and everyone held is back, so this
+ // is the new map's roster and not the snapshot taken at the map's end.
+ RosterReport report = roster.Report(At(4));
+ Assert.NotNull(report.Players);
+ Assert.Equal("de_inferno", report.Map);
+ Assert.Equal(new Counters(0, 0, 0, 0, 0, 0, 0, 0), CountersOf(report, "1"));
+ }
+
+ // The MVP comes after the round end, and a round ended again has had its
+ // MVP, whether the second end comes before or after the first MVP and
+ // whether that MVP went to a bot.
+ [Fact]
+ public void ARoundEndedAgainGetsNoSecondMvp()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ IReadOnlyDictionary teams = Teams(
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamCT)
+ );
+
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+ roster.Mvp("1", warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false);
+ roster.Mvp("2", warmup: false);
+
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+ roster.Mvp("2", warmup: false);
+ roster.Mvp("1", warmup: false);
+
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+ roster.Mvp(null, warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamCT, teams, warmup: false);
+ roster.Mvp("2", warmup: false);
+
+ RosterReport report = roster.Report(At(1));
+ Assert.Equal(1, CountersOf(report, "1").Mvps);
+ Assert.Equal(1, CountersOf(report, "2").Mvps);
+ }
+
+ [Fact]
+ public void AnMvpGoesToARosterPlayerOnly()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1")], Start);
+ roster.FreezeTimeEnded(warmup: false);
+
+ roster.Mvp("1", warmup: false);
+ roster.Mvp("1", warmup: false);
+ roster.Mvp("bot", warmup: false);
+ roster.Mvp(null, warmup: false);
+
+ Assert.Equal(2, CountersOf(roster.Report(At(1)), "1").Mvps);
+ }
+
+ // Sitting on a side without spawning, or joining the side about to win
+ // after spawning on the other, plays no round. A spawn before the round
+ // starts counts, as does a late one, and a spawn is only good for the
+ // round it was in.
+ [Fact]
+ public void ARoundIsOnlyPlayedByWhoeverSpawnedInItOnTheSideTheyEndedOn()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2"), Human("3"), Human("4")], Start);
+ IReadOnlyDictionary teams = Teams(
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamT),
+ ("3", PlayerRoster.TeamT),
+ ("4", PlayerRoster.TeamCT)
+ );
+
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("3", PlayerRoster.TeamCT));
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ Spawn(roster, ("4", PlayerRoster.TeamCT));
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ RosterReport first = roster.Report(At(1));
+ Assert.Equal((1, 1), Rounds(first, "1"));
+ Assert.Equal((0, 0), Rounds(first, "2"));
+ Assert.Equal((0, 0), Rounds(first, "3"));
+ Assert.Equal((1, 0), Rounds(first, "4"));
+
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ RosterReport second = roster.Report(At(2));
+ Assert.Equal((1, 1), Rounds(second, "1"));
+ Assert.Equal((1, 0), Rounds(second, "4"));
+ }
+
+ // The spawns of a round the map change cut short, or of warmup, are not
+ // carried into the next round.
+ [Fact]
+ public void SpawnsAreNotCarriedPastAMapChangeOrAWarmupRound()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1")], Start);
+ IReadOnlyDictionary teams = Teams(("1", PlayerRoster.TeamT));
+
+ Spawn(roster, ("1", PlayerRoster.TeamT));
+ roster.MapEnded(At(1));
+ roster.Delivered(roster.Report(At(1)), true);
+ roster.MapStarted(At(2), "de_inferno");
+ roster.Observe([Human("1")], At(3));
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ Spawn(roster, ("1", PlayerRoster.TeamT));
+ roster.FreezeTimeEnded(warmup: true);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: true);
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ Assert.Equal((0, 0), Rounds(roster.Report(At(4)), "1"));
+
+ Spawn(roster, ("1", PlayerRoster.TeamT));
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ Assert.Equal((1, 1), Rounds(roster.Report(At(5)), "1"));
+ }
+
+ // Leaving a side forgets the spawn on it, so going spectator and coming
+ // back to the same side dead plays no round, nor does a spawn from before
+ // the game restarted. Spawning again does.
+ [Fact]
+ public void LeavingASideOrARestartForgetsTheSpawnsBeforeIt()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ IReadOnlyDictionary teams = Teams(
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamT)
+ );
+
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamT));
+ roster.LeftSide("1");
+ roster.LeftSide(null);
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ RosterReport first = roster.Report(At(1));
+ Assert.Equal((0, 0), Rounds(first, "1"));
+ Assert.Equal((1, 1), Rounds(first, "2"));
+
+ Spawn(roster, ("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamT));
+ roster.GameCommencing();
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ Spawn(roster, ("2", PlayerRoster.TeamT));
+ roster.RoundEnded(PlayerRoster.TeamT, teams, warmup: false);
+
+ RosterReport second = roster.Report(At(2));
+ Assert.Equal((0, 0), Rounds(second, "1"));
+ Assert.Equal((2, 2), Rounds(second, "2"));
+ }
+
+ // Only the side a player last spawned on counts, so a side swap at the
+ // half plays the next round on the new side, and a spawn under no id is
+ // nobody's.
+ [Fact]
+ public void ARoundGoesToTheSideAPlayerLastSpawnedOn()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+
+ Spawn(
+ roster,
+ ("1", PlayerRoster.TeamCT),
+ ("1", PlayerRoster.TeamT),
+ ("2", PlayerRoster.TeamT)
+ );
+ roster.Spawned(null, PlayerRoster.TeamCT);
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(
+ PlayerRoster.TeamT,
+ Teams(("1", PlayerRoster.TeamT), ("2", PlayerRoster.TeamT)),
+ warmup: false
+ );
+
+ Spawn(roster, ("1", PlayerRoster.TeamCT), ("2", PlayerRoster.TeamCT));
+ roster.RoundStarted();
+ roster.FreezeTimeEnded(warmup: false);
+ roster.RoundEnded(
+ PlayerRoster.TeamCT,
+ Teams(("1", PlayerRoster.TeamCT), ("2", PlayerRoster.TeamT)),
+ warmup: false
+ );
+
+ RosterReport report = roster.Report(At(1));
+ Assert.Equal((2, 2), Rounds(report, "1"));
+ Assert.Equal((1, 1), Rounds(report, "2"));
+ }
+
+ private static void Spawn(PlayerRoster roster, params (string SteamId, int Team)[] spawns)
+ {
+ foreach ((string steamId, int team) in spawns)
+ {
+ roster.Spawned(steamId, team);
+ }
+ }
+
+ private static void CountEverything(PlayerRoster roster, string steamId, string enemy)
+ {
+ Spawn(roster, (steamId, PlayerRoster.TeamT), (enemy, PlayerRoster.TeamCT));
+ roster.FreezeTimeEnded(warmup: false);
+ roster.Died(enemy, steamId, warmup: false, assister: null, headshot: true);
+ roster.Died(steamId, enemy, warmup: false);
+ roster.Died(enemy, null, warmup: false, assister: steamId);
+ roster.Hurt(steamId, enemy, 64, warmup: false);
+ roster.RoundEnded(
+ PlayerRoster.TeamT,
+ Teams((steamId, PlayerRoster.TeamT), (enemy, PlayerRoster.TeamCT)),
+ warmup: false
+ );
+ roster.Mvp(steamId, warmup: false);
+ }
+
+ [Fact]
+ public async Task EveryCounterStartsOverWithANewConnectionAndDepartsWithItsFinalValue()
+ {
+ var (loop, roster, _, panel) = Loop();
+ loop.Observe([Human("1"), Human("2")], Start);
+ CountEverything(roster, "1", "2");
+ CountEverything(roster, "1", "2");
+ await loop.Tick(Start);
+
+ loop.Observe([Human("2")], At(1));
+ loop.Observe([Human("1"), Human("2")], At(2));
+ CountEverything(roster, "1", "2");
+ await loop.Tick(At(1) + PlayerRoster.Debounce);
+
+ JsonElement departed = Assert.Single(
+ panel.Bodies[1].GetProperty("departed").EnumerateArray()
+ );
+ JsonElement current = panel
+ .Bodies[1]
+ .GetProperty("players")
+ .EnumerateArray()
+ .Single(player => player.GetProperty("steam_id").GetString() == "1");
+
+ Assert.Equal(panel.Conns(0)["1"], departed.GetProperty("conn").GetString());
+ Assert.Equal(
+ new Counters(2, 2, 2, 2, 128, 2, 2, 2),
+ JsonSerializer.Deserialize(departed.GetRawText(), Snake)
+ );
+ Assert.Equal(
+ new Counters(1, 1, 1, 1, 64, 1, 1, 1),
+ JsonSerializer.Deserialize(current.GetRawText(), Snake)
+ );
+ }
+
+ private static readonly JsonSerializerOptions Snake = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ };
+
+ // A departure is a snapshot: counting more for a player who has left
+ // changes nothing about the connection that ended.
+ [Fact]
+ public void ADepartureKeepsTheCountersItLeftWith()
+ {
+ PlayerRoster roster = new();
+ roster.Observe([Human("1"), Human("2")], Start);
+ CountEverything(roster, "1", "2");
+ roster.Observe([Human("2")], At(1));
+ CountEverything(roster, "1", "2");
+
+ DepartedPlayer departed = Assert.Single(roster.Report(At(2)).Departed);
+
+ Assert.Equal(new Counters(1, 1, 1, 1, 64, 1, 1, 1), CountersOf(departed));
+ }
+
[Fact]
public void DeparturesGoOutOldestFirstAndAtMostSixtyFourAtATime()
{
@@ -1092,6 +1794,12 @@ public void ARosterSerializesAsTheApiExpects()
ip = null,
kills = 12,
deaths = 9,
+ assists = 5,
+ headshots = 7,
+ damage = 1480,
+ rounds_played = 16,
+ rounds_won = 10,
+ mvps = 3,
},
],
departed =
@@ -1102,6 +1810,12 @@ public void ARosterSerializesAsTheApiExpects()
conn = "c2",
kills = 3,
deaths = 4,
+ assists = 1,
+ headshots = 2,
+ damage = 410,
+ rounds_played = 6,
+ rounds_won = 2,
+ mvps = 1,
},
],
};
@@ -1113,11 +1827,11 @@ public void ARosterSerializesAsTheApiExpects()
Assert.Contains("\"departed\":[]", JsonSerializer.Serialize(unknown, SanctionsClient.Json));
Assert.Contains("\"players\":[]", JsonSerializer.Serialize(empty, SanctionsClient.Json));
Assert.Contains(
- "\"players\":[{\"steam_id\":\"76561198000000001\",\"conn\":\"c1\",\"name\":\"nyx\",\"ip\":null,\"kills\":12,\"deaths\":9}]",
+ "\"players\":[{\"steam_id\":\"76561198000000001\",\"conn\":\"c1\",\"name\":\"nyx\",\"ip\":null,\"kills\":12,\"deaths\":9,\"assists\":5,\"headshots\":7,\"damage\":1480,\"rounds_played\":16,\"rounds_won\":10,\"mvps\":3}]",
JsonSerializer.Serialize(known, SanctionsClient.Json)
);
Assert.Contains(
- "\"departed\":[{\"steam_id\":\"76561198000000002\",\"conn\":\"c2\",\"kills\":3,\"deaths\":4}]",
+ "\"departed\":[{\"steam_id\":\"76561198000000002\",\"conn\":\"c2\",\"kills\":3,\"deaths\":4,\"assists\":1,\"headshots\":2,\"damage\":410,\"rounds_played\":6,\"rounds_won\":2,\"mvps\":1}]",
JsonSerializer.Serialize(known, SanctionsClient.Json)
);
}
diff --git a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs
index a1cbc8c0..244f6e72 100644
--- a/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs
+++ b/shared/dotnet/FiveStack.Entities/PlayerManagement/PlayerSanction.cs
@@ -33,8 +33,9 @@ public class PlayerSanctionsRequest
public string? map { get; set; }
}
-// Kills and deaths are running totals for the connection named by conn, which
-// is new each time a player joins, so the panel never has to guess at one.
+// Kills, deaths and the counters after them are running totals for the
+// connection named by conn, which is new each time a player joins, so the
+// panel never has to guess at one.
public class RosterPlayer
{
public string steam_id { get; set; } = "";
@@ -43,6 +44,14 @@ public class RosterPlayer
public string? ip { get; set; }
public int kills { get; set; }
public int deaths { get; set; }
+
+ // Ignored by panels that predate the community counters.
+ public int assists { get; set; }
+ public int headshots { get; set; }
+ public int damage { get; set; }
+ public int rounds_played { get; set; }
+ public int rounds_won { get; set; }
+ public int mvps { get; set; }
}
public class DepartedPlayer
@@ -51,6 +60,14 @@ public class DepartedPlayer
public string conn { get; set; } = "";
public int kills { get; set; }
public int deaths { get; set; }
+
+ // Ignored by panels that predate the community counters.
+ public int assists { get; set; }
+ public int headshots { get; set; }
+ public int damage { get; set; }
+ public int rounds_played { get; set; }
+ public int rounds_won { get; set; }
+ public int mvps { get; set; }
}
public class PlayerSanctionsResponse
diff --git a/shared/dotnet/FiveStack.Utilities/HealthBook.cs b/shared/dotnet/FiveStack.Utilities/HealthBook.cs
new file mode 100644
index 00000000..394f6aa6
--- /dev/null
+++ b/shared/dotnet/FiveStack.Utilities/HealthBook.cs
@@ -0,0 +1,37 @@
+namespace FiveStack.Utilities;
+
+// Each player's health by slot, so a lethal hit is credited only with what it
+// took. A hit says what it left and a spawn clears it, but deathmatch heals a
+// player on a kill without an event, so the tick reads it off the pawn too.
+// Only touched on the game thread.
+public sealed class HealthBook
+{
+ private readonly Dictionary _health = new();
+
+ // Returns the damage the hit dealt, capped by PlayerRoster.Dealt.
+ public int Hit(int slot, int damage, int healthAfter)
+ {
+ int dealt = PlayerRoster.Dealt(
+ damage,
+ healthAfter,
+ _health.TryGetValue(slot, out int before) ? before : null
+ );
+ _health[slot] = healthAfter;
+
+ return dealt;
+ }
+
+ public void Spawned(int slot)
+ {
+ _health.Remove(slot);
+ }
+
+ // A dead pawn has no health to read; its next life starts at its spawn.
+ public void Seen(int slot, int pawnHealth)
+ {
+ if (pawnHealth > 0)
+ {
+ _health[slot] = pawnHealth;
+ }
+ }
+}
diff --git a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs
index 9404a299..3eb61211 100644
--- a/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs
+++ b/shared/dotnet/FiveStack.Utilities/PlayerRoster.cs
@@ -35,6 +35,13 @@ public class PlayerRoster
public const int MaxNameLength = 64;
+ public const int TeamT = 2;
+
+ public const int TeamCT = 3;
+
+ // A player who has not been hit since they spawned.
+ public const int FullHealth = 100;
+
private enum eRosterState
{
Unknown,
@@ -49,6 +56,12 @@ private sealed class Entry
public string? Ip;
public int Kills;
public int Deaths;
+ public int Assists;
+ public int Headshots;
+ public int Damage;
+ public int RoundsPlayed;
+ public int RoundsWon;
+ public int Mvps;
}
private sealed record Departure(DepartedPlayer Player, DateTimeOffset At);
@@ -69,6 +82,12 @@ private sealed record Departure(DepartedPlayer Player, DateTimeOffset At);
private string? _map;
private RosterReport? _final;
private DateTimeOffset _finalAt;
+ private bool _roundLive;
+ private bool _roundEnded;
+ private bool _roundMvp;
+
+ // The side each player last spawned on since the last round ended.
+ private readonly Dictionary _spawned = new();
// Returns when a sync is due if this observation is what made one due.
public virtual DateTimeOffset? Observe(IEnumerable players, DateTimeOffset now)
@@ -163,6 +182,10 @@ public void MapStarted(DateTimeOffset now, string? map)
Hold(now);
_mapStarted = true;
_releaseAt = null;
+
+ // A round the map change cut short never ends.
+ _roundLive = false;
+ _spawned.Clear();
}
}
@@ -189,9 +212,24 @@ public void Hibernating(DateTimeOffset now)
}
// Only players on the roster are counted, which keeps out bots and any
- // claimed id Steam has not verified.
- public void Died(string? victim, string? killer)
+ // claimed id Steam has not verified. The hooks pass a killer or assister
+ // only when the kill or assist counts, so a headshot only ever goes with a
+ // counted kill. Nothing done in warmup counts: its kills, deaths, damage
+ // and MVPs no more than its rounds. The game rules' warmup flag says so at
+ // each event, however long the warmup runs or however it ends.
+ public void Died(
+ string? victim,
+ string? killer,
+ bool warmup,
+ string? assister = null,
+ bool headshot = false
+ )
{
+ if (warmup)
+ {
+ return;
+ }
+
lock (_lock)
{
if (victim != null && _current.TryGetValue(victim, out Entry? dead))
@@ -202,6 +240,196 @@ public void Died(string? victim, string? killer)
if (killer != null && _current.TryGetValue(killer, out Entry? scorer))
{
scorer.Kills++;
+
+ if (headshot)
+ {
+ scorer.Headshots++;
+ }
+ }
+
+ if (
+ assister != null
+ && assister != killer
+ && _current.TryGetValue(assister, out Entry? helper)
+ )
+ {
+ helper.Assists++;
+ }
+ }
+ }
+
+ // The hooks pass an attacker only for damage to an enemy, by the same rule
+ // as a kill, and the damage already capped by Dealt.
+ public void Hurt(string? attacker, string? victim, int damage, bool warmup)
+ {
+ if (warmup || attacker == null || attacker == victim || damage <= 0)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ if (_current.TryGetValue(attacker, out Entry? entry))
+ {
+ entry.Damage += damage;
+ }
+ }
+ }
+
+ // Ending warmup ends its round as a win for whoever is alive, so the warmup
+ // flag at a round's end is not what decides it: a round only counts once
+ // its freeze time has ended outside warmup, as the match plugin's knife
+ // round does.
+ public void FreezeTimeEnded(bool warmup)
+ {
+ lock (_lock)
+ {
+ _roundLive = !warmup;
+ _roundEnded = false;
+ _roundMvp = false;
+ }
+ }
+
+ // A round counts once: another round end before the next round starts (an
+ // admin ending the round, a plugin terminating it) is not another round.
+ // The MVP can come after the round end, so the flag only goes at the
+ // next round's start.
+ public void RoundStarted()
+ {
+ lock (_lock)
+ {
+ _roundLive = false;
+ _roundEnded = false;
+ _roundMvp = false;
+ }
+ }
+
+ // Spawns are kept from one round's end to the next rather than from the
+ // round's start, so they hold whichever of round_start and the players'
+ // spawns comes first, and a player who spawns late in the round (joining
+ // in buy time) is in it too.
+ public void Spawned(string? steamId, int team)
+ {
+ if (steamId == null)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ _spawned[steamId] = team;
+ }
+ }
+
+ // A spawn only holds for the side it was on: a player who leaves it, for
+ // the other side or for spectators, has to spawn again to play the round,
+ // so going spectator and coming back dead after a restart that ended no
+ // round plays none.
+ public void LeftSide(string? steamId)
+ {
+ if (steamId == null)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ _spawned.Remove(steamId);
+ }
+ }
+
+ // The game starting or restarting ends no round anybody played, but the
+ // spawns before it are not in the round after it either.
+ public void GameCommencing()
+ {
+ lock (_lock)
+ {
+ _spawned.Clear();
+ }
+ }
+
+ // A round is played by whoever spawned in it and ends it on the side they
+ // last spawned on. Sitting on a side without spawning, or switching to the
+ // side about to win, plays no round, and a spectator plays none. Warmup
+ // rounds are not rounds.
+ public void RoundEnded(
+ int winnerTeam,
+ IReadOnlyDictionary teamsBySteamId,
+ bool warmup
+ )
+ {
+ lock (_lock)
+ {
+ try
+ {
+ Credit(winnerTeam, teamsBySteamId, warmup);
+ }
+ finally
+ {
+ _spawned.Clear();
+ }
+ }
+ }
+
+ private void Credit(
+ int winnerTeam,
+ IReadOnlyDictionary teamsBySteamId,
+ bool warmup
+ )
+ {
+ if (warmup || !_roundLive || _roundEnded)
+ {
+ return;
+ }
+
+ _roundEnded = true;
+
+ foreach ((string steamId, int team) in teamsBySteamId)
+ {
+ if (
+ (team != TeamT && team != TeamCT)
+ || !_spawned.TryGetValue(steamId, out int spawnedOn)
+ || spawnedOn != team
+ || !_current.TryGetValue(steamId, out Entry? entry)
+ )
+ {
+ continue;
+ }
+
+ entry.RoundsPlayed++;
+
+ if (team == winnerTeam)
+ {
+ entry.RoundsWon++;
+ }
+ }
+ }
+
+ // A round that never went live has no MVP either, and a round ended again
+ // has had its MVP: the MVP after its first end is the only one it gets.
+ public void Mvp(string? steamId, bool warmup)
+ {
+ if (warmup)
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ if (_roundEnded && _roundMvp)
+ {
+ return;
+ }
+
+ _roundMvp = _roundEnded;
+
+ if (
+ _roundLive
+ && steamId != null
+ && _current.TryGetValue(steamId, out Entry? entry)
+ )
+ {
+ entry.Mvps++;
}
}
}
@@ -321,6 +549,37 @@ bool teammatesAreEnemies
return attackerSlot != victimSlot && (teammatesAreEnemies || attackerTeam != victimTeam);
}
+ // A flash assist only blinded the victim, which did nothing to their health.
+ public static bool CountsAsAssist(
+ int assisterSlot,
+ int victimSlot,
+ int assisterTeam,
+ int victimTeam,
+ bool teammatesAreEnemies,
+ bool flash
+ )
+ {
+ return !flash
+ && CountsAsKill(
+ assisterSlot,
+ victimSlot,
+ assisterTeam,
+ victimTeam,
+ teammatesAreEnemies
+ );
+ }
+
+ // A hit reports its full damage even when it kills, so a lethal one is
+ // capped at the health the victim had before it: what the last hit they
+ // survived left them, or full health when they have not been hit since
+ // spawning.
+ public static int Dealt(int damage, int healthAfter, int? healthBefore)
+ {
+ int before = healthAfter > 0 ? healthAfter + damage : healthBefore ?? FullHealth;
+
+ return Math.Clamp(damage, 0, Math.Max(before, 0));
+ }
+
// The panel checks the name itself; this only keeps an empty one from
// reading as a map.
private static string? MapName(string? raw)
@@ -341,6 +600,12 @@ private List Players()
ip = entry.Value.Ip,
kills = entry.Value.Kills,
deaths = entry.Value.Deaths,
+ assists = entry.Value.Assists,
+ headshots = entry.Value.Headshots,
+ damage = entry.Value.Damage,
+ rounds_played = entry.Value.RoundsPlayed,
+ rounds_won = entry.Value.RoundsWon,
+ mvps = entry.Value.Mvps,
})
.ToList();
}
@@ -355,6 +620,12 @@ private List Departures()
conn = departure.Player.conn,
kills = departure.Player.kills,
deaths = departure.Player.deaths,
+ assists = departure.Player.assists,
+ headshots = departure.Player.headshots,
+ damage = departure.Player.damage,
+ rounds_played = departure.Player.rounds_played,
+ rounds_won = departure.Player.rounds_won,
+ mvps = departure.Player.mvps,
})
.ToList();
}
@@ -446,6 +717,12 @@ private void Reconcile(DateTimeOffset now)
conn = entry.Conn,
kills = entry.Kills,
deaths = entry.Deaths,
+ assists = entry.Assists,
+ headshots = entry.Headshots,
+ damage = entry.Damage,
+ rounds_played = entry.RoundsPlayed,
+ rounds_won = entry.RoundsWon,
+ mvps = entry.Mvps,
},
now
)