Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
ebe2605
feature: community servers report each map's final counts and name th…
Flegma Oct 29, 2026
1b0d154
bug: a map-end report the panel refuses is retried on the interval, a…
Flegma Oct 29, 2026
67c1452
feature: Player Management counts assists, headshots, damage, rounds …
Flegma Oct 30, 2026
5053b93
bug: nothing a player does in warmup counts toward the community stats
Flegma Oct 30, 2026
f58b701
test: a sync in flight at a map change does not stand in for the map-…
Flegma Nov 1, 2026
fb4ec94
Merge branch 'feature/community-session-map' into feature/community-m…
Flegma Nov 1, 2026
6507afd
bug: a lethal hit after a deathmatch heal counts the health the playe…
Flegma Nov 1, 2026
f29e317
bug: ending warmup hands out no round or MVP, since a round only coun…
Flegma Nov 1, 2026
ef9b76f
bug: a round counts once, and the flag that lets it count goes at the…
Flegma Nov 1, 2026
0d95445
bug: a round ended again has had its MVP, and the map change test che…
Flegma Nov 1, 2026
681515f
bug: an MVP to a bot or to a player who has gone reaches the roster t…
Flegma Nov 1, 2026
5cab23f
Merge remote-tracking branch 'origin/main' into feature/community-mor…
Flegma Nov 2, 2026
12b87f0
bug: a round only goes to players who spawned in it and end it on the…
Flegma Nov 2, 2026
fdd2d91
bug: a spawn is read off the player's own pawn and kept under the id …
Flegma Nov 2, 2026
713eaf2
bug: a player leaving the server forgets their spawn on sw too, as on…
Flegma Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/player-management-css/src/PlayerManagement.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<Compile Include="$(MSBuildThisFileDirectory)../../counterstrikesharp/src/FiveStack.Services/SteamAPI.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../counterstrikesharp/src/FiveStack.Services/NetworkServices.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/PlayerRoster.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/HealthBook.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/ChatUtility.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/HttpClientProvider.cs" LinkBase="Shared" />
Expand Down
278 changes: 276 additions & 2 deletions apps/player-management-css/src/PlayerManagementPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
private readonly HashSet<ulong> _mutedByUs = new();
private readonly HashSet<ulong> _kicked = new();

private readonly HealthBook _health = new();

private long _lastEnforceMs;

private INetworkServerService? _networkServerService;
Expand Down Expand Up @@ -158,6 +160,16 @@
{
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();
Expand Down Expand Up @@ -260,6 +272,7 @@
List<CCSPlayerController> humans = Humans();

ObserveRoster(humans);
ReadHealth(humans);
Enforce(humans);
}
catch (Exception error)
Expand Down Expand Up @@ -401,6 +414,8 @@
}

CCSPlayerController? attacker = @event.Attacker;
CCSPlayerController? assister = @event.Assister;
bool teammatesAreEnemies = TeammatesAreEnemies();

bool kill =
attacker != null
Expand All @@ -410,10 +425,28 @@
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)
{
Expand All @@ -423,6 +456,208 @@
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<string, int> 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<CCSPlayerController> humans)
Expand All @@ -437,12 +672,34 @@
}
}

// Also kept apart from the enforcement. A pawn that cannot be read keeps
// the health its last hit left.
private void ReadHealth(List<CCSPlayerController> 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()
{
try
{
return ConVar.Find("mp_teammates_are_enemies")?.GetPrimitiveValue<bool>() ?? false;

Check warning on line 702 in apps/player-management-css/src/PlayerManagementPlugin.cs

View workflow job for this annotation

GitHub Actions / player-management-build (player-management-css)

'ConVar' is obsolete: 'Use ConVar<T> instead for type-safe access to ConVars.'

Check warning on line 702 in apps/player-management-css/src/PlayerManagementPlugin.cs

View workflow job for this annotation

GitHub Actions / player-management-build (player-management-css)

'ConVar' is obsolete: 'Use ConVar<T> instead for type-safe access to ConVars.'
}
catch (Exception)
{
Expand All @@ -450,6 +707,23 @@
}
}

// Unreadable is taken as live, as a server without warmup is.
private static bool IsWarmup()
{
try
{
return Utilities
.FindAllEntitiesByDesignerName<CCSGameRulesProxy>("cs_gamerules")
.FirstOrDefault()
?.GameRules?.WarmupPeriod
?? false;
}
catch (Exception)
{
return false;
}
}

private HookResult OnChat(CCSPlayerController? player, CommandInfo info)
{
if (player == null || !IsHuman(player))
Expand Down
1 change: 1 addition & 0 deletions apps/player-management-sw/src/PlayerManagement.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/SteamIdUtility.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/SteamRelayUtility.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/PlayerRoster.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/HealthBook.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/PlayerManagementReport.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/ChatUtility.cs" LinkBase="Shared" />
<Compile Include="$(MSBuildThisFileDirectory)../../../shared/dotnet/FiveStack.Utilities/HttpClientProvider.cs" LinkBase="Shared" />
Expand Down
Loading
Loading