0.2.0: log kills, bomb plants and defuses as they happen; leave warmup out of match stats

This commit is contained in:
Astra 2026-10-04 21:36:31 +01:00
parent 905acd8f1d
commit bba0471f20
5 changed files with 144 additions and 9 deletions

View file

@ -14,14 +14,16 @@ namespace PlaytimeHistory;
// Records every player's time on the server in its own MySQL database: sessions (join to leave, or
// to a map change) and, inside them, segments, each a stretch where the player's team and
// alive/dead state stayed the same. Per-hour, per-team and alive/dead playtime all come from the
// segments with SQL (see the README). Only country and city are kept from a player's IP.
// segments with SQL (see the README). Kills, bomb plants and defuses are logged as they happen.
// Warmup counts as playtime, but not toward match stats or events. Only country and city are kept
// from a player's IP.
//
// Threads: every field below is only touched on the game thread. Database writes are queued to the
// Writer; command lookups run in Task.Run and hand their answer back with Server.NextFrame.
public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
{
public override string ModuleName => "PlaytimeHistory";
public override string ModuleVersion => "0.1.3";
public override string ModuleVersion => "0.2.0";
public override string ModuleAuthor => "astra";
public override string ModuleDescription => "Records playtime per session, team and alive/dead state";
@ -92,6 +94,8 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect, HookMode.Pre);
RegisterEventHandler<EventRoundEnd>(OnRoundEnd);
RegisterEventHandler<EventBombPlanted>(OnBombPlanted);
RegisterEventHandler<EventBombDefused>(OnBombDefused);
OpenGeo();
@ -185,6 +189,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
private static string NewKey() => Guid.NewGuid().ToString("N");
private static bool InWarmup() =>
Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>("cs_gamerules").FirstOrDefault()?.GameRules?.WarmupPeriod ?? false;
private static bool Trackable(CCSPlayerController? player) =>
player is { IsValid: true, IsBot: false, IsHLTV: false } &&
player.Connected == PlayerConnectedState.Connected && player.SteamID != 0;
@ -208,6 +215,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
if (_writer == null || !_mapRunning)
return;
var now = Now();
var warmup = InWarmup();
var seen = new HashSet<int>();
for (var slot = 0; slot < Server.MaxPlayers; slot++)
{
@ -225,7 +233,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
else
{
UpdateState(tracked, player!, now);
SampleStats(tracked, player!);
SampleStats(tracked, player!, warmup);
}
}
foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList())
@ -254,7 +262,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
{
var location = LookupLocation(player.IpAddress);
var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey() };
SampleStats(tracked, player);
SampleStats(tracked, player, InWarmup());
_tracked[slot] = tracked;
_writer!.Enqueue(new SessionOpen(tracked.SessionKey, tracked.SteamId, Config.ServerId, player.PlayerName, now, _map,
_tracked.Count, Server.MaxPlayers, location?.CountryIso, location?.CountryName, location?.City, ModuleVersion));
@ -292,7 +300,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
_writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime));
if (player != null)
SampleStats(tracked, player);
SampleStats(tracked, player, InWarmup());
var stats = tracked.Stats;
var ping = tracked.PingSamples > 0
? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples)
@ -335,15 +343,16 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
// Adds what the map's running stats gained since the last read to the session's total. The first
// read is only the starting point: what's there already happened before this session. Kills and
// score can go down (a teamkill), but deaths and damage only go down when the game resets the
// stats (the match starting after warmup, say); then all of the new values count.
private static void SampleStats(Tracked tracked, CCSPlayerController player)
// stats (the match starting after warmup, say); then all of the new values count. During warmup
// the reads only move the starting point along: warmup counts as playtime, not toward stats.
private static void SampleStats(Tracked tracked, CCSPlayerController player, bool warmup)
{
var now = ReadStats(player);
if (now == null)
return;
var last = tracked.LastStats;
tracked.LastStats = now;
if (last == null)
if (last == null || warmup)
{
tracked.Stats ??= new MatchStats(0, 0, 0, 0, 0, 0, 0);
return;
@ -452,9 +461,49 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
{
UpdateNextFrame(@event.Userid);
RecordKill(@event);
return HookResult.Continue;
}
// Logs a kill by a tracked player. Not suicides, team kills or anything in warmup.
private void RecordKill(EventPlayerDeath @event)
{
var killer = @event.Attacker;
var victim = @event.Userid;
if (_writer == null || !_mapRunning || !Trackable(killer) || victim is not { IsValid: true } ||
victim.Slot == killer!.Slot || victim.Team == killer.Team || InWarmup())
return;
var bot = victim.IsBot;
_writer.Enqueue(new EventRecord(NewKey(), Config.ServerId, SessionKeyOf(killer), killer.SteamID, EventKind.Kill, Now(),
_map, (int)killer.Team, Weapon: @event.Weapon, Headshot: @event.Headshot, Noscope: @event.Noscope,
Thrusmoke: @event.Thrusmoke, Wallbang: @event.Penetrated > 0, Blind: @event.Attackerblind,
VictimSteamId: bot || victim.SteamID == 0 ? null : victim.SteamID, VictimBot: bot));
}
private HookResult OnBombPlanted(EventBombPlanted @event, GameEventInfo info)
{
RecordBomb(@event.Userid, EventKind.BombPlanted, @event.Site);
return HookResult.Continue;
}
private HookResult OnBombDefused(EventBombDefused @event, GameEventInfo info)
{
RecordBomb(@event.Userid, EventKind.BombDefused, @event.Site);
return HookResult.Continue;
}
private void RecordBomb(CCSPlayerController? player, EventKind kind, int site)
{
if (_writer == null || !_mapRunning || !Trackable(player) || InWarmup())
return;
_writer.Enqueue(new EventRecord(NewKey(), Config.ServerId, SessionKeyOf(player!), player!.SteamID, kind, Now(),
_map, (int)player.Team, Site: site));
}
// The session open for this player, if it's theirs.
private string? SessionKeyOf(CCSPlayerController player) =>
_tracked.TryGetValue(player.Slot, out var tracked) && tracked.SteamId == player.SteamID ? tracked.SessionKey : null;
// The controller and pawn have caught up with the event by the next frame.
private void UpdateNextFrame(CCSPlayerController? player)
{
@ -466,6 +515,8 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
private HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info)
{
if (InWarmup())
return HookResult.Continue;
foreach (var tracked in _tracked.Values)
if (Teams.Playing(tracked.Team))
tracked.Rounds++;