From d06e240417d33c8caa5a4bf24351250612ffcf82 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 18:18:21 +0100 Subject: [PATCH 1/5] 0.1.2: update the hostname once server.cfg has set it --- PlaytimeHistory.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/PlaytimeHistory.cs b/PlaytimeHistory.cs index b4e3df4..e6de1e7 100644 --- a/PlaytimeHistory.cs +++ b/PlaytimeHistory.cs @@ -21,7 +21,7 @@ namespace PlaytimeHistory; public class PlaytimeHistory : BasePlugin, IPluginConfig { public override string ModuleName => "PlaytimeHistory"; - public override string ModuleVersion => "0.1.1"; + public override string ModuleVersion => "0.1.2"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Records playtime per session, team and alive/dead state"; @@ -35,6 +35,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig private bool _geoBroken; private string _map = ""; + // The hostname last written to pt_servers. The server.cfg that sets it runs after map start, so + // the heartbeat checks again and sends it when it's changed. + private string? _sentHostname; // False between a map's end and the next one's start, when nothing is opened. private bool _mapRunning; @@ -115,7 +118,12 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig ServerSeenNow(); AddTimer(1.0f, Reconcile, TimerFlags.REPEAT); - AddTimer(Config.HeartbeatSeconds, () => _writer.Enqueue(new Heartbeat(Config.ServerId, Now())), TimerFlags.REPEAT); + AddTimer(Config.HeartbeatSeconds, () => + { + _writer.Enqueue(new Heartbeat(Config.ServerId, Now())); + if (_mapRunning) + ServerSeenNow(onlyIfChanged: true); + }, TimerFlags.REPEAT); AddTimer(Config.PingSampleIntervalSeconds, SamplePing, TimerFlags.REPEAT); if (Config.OnlineSnapshotIntervalSeconds > 0) AddTimer(Config.OnlineSnapshotIntervalSeconds, TakeSnapshot, TimerFlags.REPEAT); @@ -178,9 +186,12 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig player is { IsValid: true, IsBot: false, IsHLTV: false } && player.Connected == PlayerConnectedState.Connected && player.SteamID != 0; - private void ServerSeenNow() + private void ServerSeenNow(bool onlyIfChanged = false) { var hostname = ConVar.Find("hostname")?.StringValue ?? ""; + if (onlyIfChanged && hostname == _sentHostname) + return; + _sentHostname = hostname; _writer?.Enqueue(new ServerSeen(Config.ServerId, Config.PublicAddress, hostname, Now())); } From a02d8ee222229bdcf5c5536252a5c7c3ab6cb63d Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 18:31:28 +0100 Subject: [PATCH 2/5] 0.1.3: keep match stats per session as it goes, so map changes record them --- PlaytimeHistory.cs | 60 +++++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/PlaytimeHistory.cs b/PlaytimeHistory.cs index e6de1e7..74528a2 100644 --- a/PlaytimeHistory.cs +++ b/PlaytimeHistory.cs @@ -21,7 +21,7 @@ namespace PlaytimeHistory; public class PlaytimeHistory : BasePlugin, IPluginConfig { public override string ModuleName => "PlaytimeHistory"; - public override string ModuleVersion => "0.1.2"; + public override string ModuleVersion => "0.1.3"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Records playtime per session, team and alive/dead state"; @@ -50,7 +50,10 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig { public required ulong SteamId; public required string SessionKey; - public required MatchStats? Baseline; + // The map's running stats as last read, and this session's total so far (null until they've + // been read once). Read every second, so a close that can't read them still has them. + public MatchStats? LastStats; + public MatchStats? Stats; public string? SegmentKey; public DateTime SegmentStart; public int Team; @@ -220,7 +223,10 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig if (tracked == null) OpenSession(slot, player!, now); else + { UpdateState(tracked, player!, now); + SampleStats(tracked, player!); + } } foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList()) CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null); @@ -247,7 +253,8 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig private void OpenSession(int slot, CCSPlayerController player, DateTime now) { var location = LookupLocation(player.IpAddress); - var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey(), Baseline = ReadStats(player) }; + var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey() }; + SampleStats(tracked, player); _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)); @@ -284,7 +291,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig if (tracked.SegmentKey != null) _writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime)); - var stats = player != null ? Delta(ReadStats(player), tracked.Baseline) : null; + if (player != null) + SampleStats(tracked, player); + var stats = tracked.Stats; var ping = tracked.PingSamples > 0 ? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples) : null; @@ -310,24 +319,41 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig private static MatchStats? ReadStats(CCSPlayerController player) { - var stats = player.ActionTrackingServices?.MatchStats; - if (stats == null) + try + { + var stats = player.ActionTrackingServices?.MatchStats; + if (stats == null) + return null; + return new MatchStats(stats.Kills, stats.Deaths, stats.Assists, stats.HeadShotKills, stats.Damage, player.MVPs, player.Score); + } + catch (Exception) + { return null; - return new MatchStats(stats.Kills, stats.Deaths, stats.Assists, stats.HeadShotKills, stats.Damage, player.MVPs, player.Score); + } } - // This session's share of the map's running totals. Totals that went down were reset, so they - // count from zero. - private static MatchStats? Delta(MatchStats? now, MatchStats? baseline) + // 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) { + var now = ReadStats(player); if (now == null) - return null; - if (baseline == null) - return now; - static int D(int n, int b) => n >= b ? n - b : n; - return new MatchStats(D(now.Kills, baseline.Kills), D(now.Deaths, baseline.Deaths), D(now.Assists, baseline.Assists), - D(now.Headshots, baseline.Headshots), D(now.Damage, baseline.Damage), D(now.Mvp, baseline.Mvp), - D(now.Score, baseline.Score)); + return; + var last = tracked.LastStats; + tracked.LastStats = now; + if (last == null) + { + tracked.Stats ??= new MatchStats(0, 0, 0, 0, 0, 0, 0); + return; + } + if (now.Deaths < last.Deaths || now.Damage < last.Damage) + last = new MatchStats(0, 0, 0, 0, 0, 0, 0); + var s = tracked.Stats!; + tracked.Stats = new MatchStats(s.Kills + now.Kills - last.Kills, s.Deaths + now.Deaths - last.Deaths, + s.Assists + now.Assists - last.Assists, s.Headshots + now.Headshots - last.Headshots, + s.Damage + now.Damage - last.Damage, s.Mvp + now.Mvp - last.Mvp, s.Score + now.Score - last.Score); } private Geo.Location? LookupLocation(string? address) From 905acd8f1d3a120873b43c3205664a752b0a50e1 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 18:34:15 +0100 Subject: [PATCH 3/5] release.sh: read the Forgejo token from ../../.forgejo-token when FORGEJO_TOKEN isn't set --- release.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/release.sh b/release.sh index b190334..1ee7009 100755 --- a/release.sh +++ b/release.sh @@ -22,8 +22,13 @@ set -euo pipefail FORGEJO_URL="https://git.zio.sh" OWNER="cs2" REPO="PlaytimeHistory" -# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository. -# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token. +# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the +# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their +# repos, mode 600). Don't commit a real token. +TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token" +if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then + FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +fi FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" # Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj @@ -44,7 +49,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 exit 1 fi for tool in podman jq curl git; do From bba0471f20fdb7602ea9e4ab0e1b8e8c73314f3c Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 21:36:31 +0100 Subject: [PATCH 4/5] 0.2.0: log kills, bomb plants and defuses as they happen; leave warmup out of match stats --- Database.cs | 14 ++++++++ Migrations/002_Events.sql | 30 ++++++++++++++++++ Ops.cs | 14 ++++++++ PlaytimeHistory.cs | 67 ++++++++++++++++++++++++++++++++++----- README.md | 28 +++++++++++++++- 5 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 Migrations/002_Events.sql diff --git a/Database.cs b/Database.cs index 91080be..3df07a4 100644 --- a/Database.cs +++ b/Database.cs @@ -155,6 +155,20 @@ public sealed class Database ("@max", o.MaxPlayers), ("@map", Clip(o.Map, 64))); break; + case EventRecord o: + await Exec(connection, transaction, token, """ + INSERT IGNORE INTO pt_events + (event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot, noscope, + thrusmoke, wallbang, blind, victim_steamid64, victim_bot, site) + VALUES (@key, @server, @session, @steamid, @kind, @at, @map, @team, @weapon, @headshot, @noscope, + @thrusmoke, @wallbang, @blind, @victim, @victimBot, @site) + """, ("@key", o.EventKey), ("@server", o.ServerId), ("@session", o.SessionKey), ("@steamid", o.SteamId), + ("@kind", (int)o.Kind), ("@at", o.At), ("@map", Clip(o.Map, 64)), ("@team", o.Team), + ("@weapon", Clip(o.Weapon, 64)), ("@headshot", o.Headshot), ("@noscope", o.Noscope), + ("@thrusmoke", o.Thrusmoke), ("@wallbang", o.Wallbang), ("@blind", o.Blind), + ("@victim", o.VictimSteamId), ("@victimBot", o.VictimBot), ("@site", o.Site)); + break; + default: throw new ArgumentException($"Unknown op {op.GetType().Name}"); } diff --git a/Migrations/002_Events.sql b/Migrations/002_Events.sql new file mode 100644 index 0000000..76844f6 --- /dev/null +++ b/Migrations/002_Events.sql @@ -0,0 +1,30 @@ +-- One row per kill, bomb plant and bomb defuse, written as it happens (0.2.0+). Nothing during +-- warmup. Kills leave out suicides and team kills. +-- kind: 1 kill, 2 bomb planted, 3 bomb defused. steamid64 is who did it: the killer, planter or +-- defuser. The kill-only columns are NULL for plants and defuses, and site is NULL for kills. +-- victim_steamid64 is NULL when the victim was a bot (victim_bot 1). +CREATE TABLE IF NOT EXISTS `pt_events` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `event_key` char(32) NOT NULL, + `server_id` int NOT NULL, + `session_key` char(32) NULL, + `steamid64` bigint unsigned NOT NULL, + `kind` tinyint NOT NULL, + `at` datetime NOT NULL, + `map` varchar(64) NOT NULL, + `team` tinyint NOT NULL, + `weapon` varchar(64) NULL, + `headshot` tinyint NULL, + `noscope` tinyint NULL, + `thrusmoke` tinyint NULL, + `wallbang` tinyint NULL, + `blind` tinyint NULL, + `victim_steamid64` bigint unsigned NULL, + `victim_bot` tinyint NULL, + `site` int NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_event_key` (`event_key`), + KEY `ix_kind_at` (`kind`, `at`), + KEY `ix_steamid_at` (`steamid64`, `at`), + KEY `ix_at` (`at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/Ops.cs b/Ops.cs index a6c833c..8922e42 100644 --- a/Ops.cs +++ b/Ops.cs @@ -16,6 +16,7 @@ namespace PlaytimeHistory; [JsonDerivedType(typeof(SegmentClose), "segment_close")] [JsonDerivedType(typeof(Heartbeat), "heartbeat")] [JsonDerivedType(typeof(OnlineSnapshot), "online_snapshot")] +[JsonDerivedType(typeof(EventRecord), "event")] public abstract record Op; public sealed record ServerSeen(int ServerId, string Address, string Hostname, DateTime At) : Op; @@ -47,6 +48,12 @@ public sealed record Heartbeat(int ServerId, DateTime At) : Op; public sealed record OnlineSnapshot(int ServerId, DateTime At, int Players, int Bots, int MaxPlayers, string Map) : Op; +// A kill, bomb plant or bomb defuse, as it happens. The kill-only fields are null otherwise. +public sealed record EventRecord( + string EventKey, int ServerId, string? SessionKey, ulong SteamId, EventKind Kind, DateTime At, string Map, int Team, + string? Weapon = null, bool? Headshot = null, bool? Noscope = null, bool? Thrusmoke = null, bool? Wallbang = null, + bool? Blind = null, ulong? VictimSteamId = null, bool? VictimBot = null, int? Site = null) : Op; + public sealed record MatchStats(int Kills, int Deaths, int Assists, int Headshots, int Damage, int Mvp, int Score); public sealed record PingStats(int Avg, int Min, int Max, int Samples); @@ -60,6 +67,13 @@ public enum EndKind Stale = 4, } +public enum EventKind +{ + Kill = 1, + BombPlanted = 2, + BombDefused = 3, +} + public static class Teams { public const int None = 0; diff --git a/PlaytimeHistory.cs b/PlaytimeHistory.cs index 74528a2..0f6e2e4 100644 --- a/PlaytimeHistory.cs +++ b/PlaytimeHistory.cs @@ -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 { 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 RegisterEventHandler(OnPlayerDeath); RegisterEventHandler(OnPlayerDisconnect, HookMode.Pre); RegisterEventHandler(OnRoundEnd); + RegisterEventHandler(OnBombPlanted); + RegisterEventHandler(OnBombDefused); OpenGeo(); @@ -185,6 +189,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig private static string NewKey() => Guid.NewGuid().ToString("N"); + private static bool InWarmup() => + Utilities.FindAllEntitiesByDesignerName("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 if (_writer == null || !_mapRunning) return; var now = Now(); + var warmup = InWarmup(); var seen = new HashSet(); for (var slot = 0; slot < Server.MaxPlayers; slot++) { @@ -225,7 +233,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig 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 { 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 _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 // 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 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 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++; diff --git a/README.md b/README.md index 3c34e31..e031aab 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,16 @@ Records every player's time on the server in its own MySQL/MariaDB database: - **Segments** inside each session: stretches where the player's team (unassigned, spectator, T, CT) and alive/dead state didn't change. Per-hour, per-team, alive/dead and spectator time all come from these with SQL; see [Queries](#queries). +- **Events**, as they happen: every kill (weapon, headshot, no-scope, through smoke, wallbang, + blind, victim), bomb plant and bomb defuse (0.2.0+). - Running totals per player, nickname history, and a count of players online every 5 minutes. It replaces ConnectHistory. Player IPs are never stored: the IP is only used, on join, to look up a country and city. -Warmup counts like any other time. Bots and SourceTV are ignored. +Warmup counts as playtime like any other time, but not toward match stats (kills, deaths, assists, +headshots, damage, MVPs, score, rounds) or events (0.2.0+; before that it counted for everything). +Bots and SourceTV are ignored, except that a kill of a bot is logged with `victim_bot` 1. ## Setup @@ -90,6 +94,11 @@ Every second, and right after `player_team`, `player_spawn`, `player_death` and change closes the current segment and opens the next, so segment times are exact to the second. Sessions close on `player_disconnect` (with the reason), on map end and on unload. +Match stats come from the game's own per-player counters, read every second. Only what they gain +outside warmup counts, so the session's totals leave warmup out. `player_death`, `bomb_planted` and +`bomb_defused` are logged to `pt_events` the moment they happen, except in warmup. A kill is logged +for a tracked killer; suicides and team kills aren't. + Database writes run in order on one background task, never on the game thread. When the database is unreachable (or refuses the login), writes go to `plugins/PlaytimeHistory/spool.jsonl` and are sent, in order, once it's back; `css_pt_status` shows how many are waiting. Writes are keyed, so sending @@ -111,6 +120,7 @@ All times are UTC (`UTC_TIMESTAMP()`, not `NOW()`, when comparing). | `pt_nicknames` | Every name a SteamID has used, with first/last seen and times seen. | | `pt_servers` | `id`, `address`, `hostname`, first/last seen. | | `pt_online_snapshots` | Players (humans), bots and slots online, every `OnlineSnapshotIntervalSeconds`. | +| `pt_events` | `kind` 1 kill, 2 bomb planted, 3 bomb defused; `steamid64` is who did it, with `at`, `map`, `team` and `session_key`. Kills add `weapon` (as the game names it: `taser` is the Zeus, knives are `knife`, `knife_t`, `bayonet` or `knife_`), `headshot`, `noscope`, `thrusmoke`, `wallbang`, `blind`, `victim_steamid64` and `victim_bot`. Plants and defuses add `site`. | | `pt_migrations` | Applied migrations. | ## Queries @@ -180,6 +190,22 @@ WHERE steamid64 = ? AND ended_at IS NOT NULL AND started_at >= '2026-10-01' AND started_at < '2026-11-01'; ``` +The most bomb plants, defuses, knife kills, Zeus kills and headshots in the last 7 days (kills of +bots left out): + +```sql +SELECT p.last_nickname, e.steamid64, + SUM(e.kind = 2) AS plants, SUM(e.kind = 3) AS defuses, + SUM(e.kind = 1 AND (e.weapon LIKE 'knife%' OR e.weapon = 'bayonet') AND NOT e.victim_bot) AS knife_kills, + SUM(e.kind = 1 AND e.weapon = 'taser' AND NOT e.victim_bot) AS zeus_kills, + SUM(e.kind = 1 AND e.headshot AND NOT e.victim_bot) AS headshots +FROM pt_events e JOIN pt_players p USING (steamid64) +WHERE e.at >= UTC_TIMESTAMP() - INTERVAL 7 DAY +GROUP BY e.steamid64 +ORDER BY plants DESC +LIMIT 10; +``` + Who's online now: ```sql From 2576a8cc3750be4db3ada91365d1c397180acae1 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 21:59:48 +0100 Subject: [PATCH 5/5] 0.2.1: count queued writes in the writer, so css_pt_status doesn't throw --- PlaytimeHistory.cs | 2 +- Writer.cs | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/PlaytimeHistory.cs b/PlaytimeHistory.cs index 0f6e2e4..ac67b8d 100644 --- a/PlaytimeHistory.cs +++ b/PlaytimeHistory.cs @@ -23,7 +23,7 @@ namespace PlaytimeHistory; public class PlaytimeHistory : BasePlugin, IPluginConfig { public override string ModuleName => "PlaytimeHistory"; - public override string ModuleVersion => "0.2.0"; + public override string ModuleVersion => "0.2.1"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Records playtime per session, team and alive/dead state"; diff --git a/Writer.cs b/Writer.cs index 1ac4df3..e27aff7 100644 --- a/Writer.cs +++ b/Writer.cs @@ -38,7 +38,9 @@ public sealed class Writer _logger = logger; } - public int Queued => _queue.Reader.Count; + // Kept here: a single-reader unbounded channel can't count itself (Reader.Count throws). + private int _queued; + public int Queued => Volatile.Read(ref _queued); public int Spooled => _spooled; public bool Migrated => Volatile.Read(ref _migrated); public bool Failing => Volatile.Read(ref _failing); @@ -48,7 +50,11 @@ public sealed class Writer public void Start() => _loop = Task.Run(RunAsync); // From the game thread. Never blocks. - public void Enqueue(Op op) => _queue.Writer.TryWrite(op); + public void Enqueue(Op op) + { + if (_queue.Writer.TryWrite(op)) + Interlocked.Increment(ref _queued); + } // On unload: sends what's queued, waiting up to `timeout`, then spools anything left. public void Drain(TimeSpan timeout) @@ -66,7 +72,10 @@ public sealed class Writer } } while (_queue.Reader.TryRead(out var op)) + { + Interlocked.Decrement(ref _queued); Spool(op); + } } private async Task RunAsync() @@ -87,7 +96,10 @@ public sealed class Writer } while (_queue.Reader.TryRead(out var op)) + { + Interlocked.Decrement(ref _queued); await HandleAsync(op, token); + } bool more; try