diff --git a/Database.cs b/Database.cs index 3df07a4..91080be 100644 --- a/Database.cs +++ b/Database.cs @@ -155,20 +155,6 @@ 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 deleted file mode 100644 index 76844f6..0000000 --- a/Migrations/002_Events.sql +++ /dev/null @@ -1,30 +0,0 @@ --- 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 8922e42..a6c833c 100644 --- a/Ops.cs +++ b/Ops.cs @@ -16,7 +16,6 @@ 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; @@ -48,12 +47,6 @@ 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); @@ -67,13 +60,6 @@ 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 ac67b8d..75cb5e2 100644 --- a/PlaytimeHistory.cs +++ b/PlaytimeHistory.cs @@ -14,16 +14,14 @@ 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). 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. +// segments with SQL (see the README). 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.2.1"; + public override string ModuleVersion => "0.1.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Records playtime per session, team and alive/dead state"; @@ -37,9 +35,6 @@ 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; @@ -52,10 +47,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig { public required ulong SteamId; public required string SessionKey; - // 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 required MatchStats? Baseline; public string? SegmentKey; public DateTime SegmentStart; public int Team; @@ -81,9 +73,6 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig public override void Load(bool hotReload) { ProtectConfigFile(); - // Null while the server is still starting, before the first map: OnMapStart sets it. - _map = Server.MapName ?? ""; - _mapRunning = _map.Length > 0; RegisterListener(OnMapStart); RegisterListener(OnMapEnd); @@ -94,8 +83,6 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig RegisterEventHandler(OnPlayerDeath); RegisterEventHandler(OnPlayerDisconnect, HookMode.Pre); RegisterEventHandler(OnRoundEnd); - RegisterEventHandler(OnBombPlanted); - RegisterEventHandler(OnBombDefused); OpenGeo(); @@ -121,16 +108,20 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig Logger.LogInformation("Recording to {Host}:{Port}/{Database} as {User}, server id {Id}", Config.Database.Host, Config.Database.Port, Config.Database.Database, Config.Database.User, Config.ServerId); + try + { + _map = Server.MapName; + } + catch (Exception) + { + _map = ""; + } + _mapRunning = _map.Length > 0; if (_mapRunning) ServerSeenNow(); AddTimer(1.0f, Reconcile, TimerFlags.REPEAT); - AddTimer(Config.HeartbeatSeconds, () => - { - _writer.Enqueue(new Heartbeat(Config.ServerId, Now())); - if (_mapRunning) - ServerSeenNow(onlyIfChanged: true); - }, TimerFlags.REPEAT); + AddTimer(Config.HeartbeatSeconds, () => _writer.Enqueue(new Heartbeat(Config.ServerId, Now())), TimerFlags.REPEAT); AddTimer(Config.PingSampleIntervalSeconds, SamplePing, TimerFlags.REPEAT); if (Config.OnlineSnapshotIntervalSeconds > 0) AddTimer(Config.OnlineSnapshotIntervalSeconds, TakeSnapshot, TimerFlags.REPEAT); @@ -189,19 +180,13 @@ 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; - private void ServerSeenNow(bool onlyIfChanged = false) + private void ServerSeenNow() { var hostname = ConVar.Find("hostname")?.StringValue ?? ""; - if (onlyIfChanged && hostname == _sentHostname) - return; - _sentHostname = hostname; _writer?.Enqueue(new ServerSeen(Config.ServerId, Config.PublicAddress, hostname, Now())); } @@ -215,7 +200,6 @@ 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++) { @@ -231,10 +215,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig if (tracked == null) OpenSession(slot, player!, now); else - { UpdateState(tracked, player!, now); - SampleStats(tracked, player!, warmup); - } } foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList()) CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null); @@ -261,8 +242,7 @@ 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() }; - SampleStats(tracked, player, InWarmup()); + var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey(), Baseline = ReadStats(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)); @@ -299,9 +279,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig if (tracked.SegmentKey != null) _writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime)); - if (player != null) - SampleStats(tracked, player, InWarmup()); - var stats = tracked.Stats; + var stats = player != null ? Delta(ReadStats(player), tracked.Baseline) : null; var ping = tracked.PingSamples > 0 ? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples) : null; @@ -327,42 +305,24 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig private static MatchStats? ReadStats(CCSPlayerController player) { - 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) - { + 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); } - // 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. 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) + // 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) { - var now = ReadStats(player); if (now == null) - return; - var last = tracked.LastStats; - tracked.LastStats = now; - if (last == null || warmup) - { - 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); + 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)); } private Geo.Location? LookupLocation(string? address) @@ -461,49 +421,9 @@ 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) { @@ -515,8 +435,6 @@ 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 e031aab..3c34e31 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,12 @@ 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 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. +Warmup counts like any other time. Bots and SourceTV are ignored. ## Setup @@ -94,11 +90,6 @@ 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 @@ -120,7 +111,6 @@ 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 @@ -190,22 +180,6 @@ 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 diff --git a/Writer.cs b/Writer.cs index e27aff7..1ac4df3 100644 --- a/Writer.cs +++ b/Writer.cs @@ -38,9 +38,7 @@ public sealed class Writer _logger = logger; } - // 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 Queued => _queue.Reader.Count; public int Spooled => _spooled; public bool Migrated => Volatile.Read(ref _migrated); public bool Failing => Volatile.Read(ref _failing); @@ -50,11 +48,7 @@ public sealed class Writer public void Start() => _loop = Task.Run(RunAsync); // From the game thread. Never blocks. - public void Enqueue(Op op) - { - if (_queue.Writer.TryWrite(op)) - Interlocked.Increment(ref _queued); - } + public void Enqueue(Op op) => _queue.Writer.TryWrite(op); // On unload: sends what's queued, waiting up to `timeout`, then spools anything left. public void Drain(TimeSpan timeout) @@ -72,10 +66,7 @@ public sealed class Writer } } while (_queue.Reader.TryRead(out var op)) - { - Interlocked.Decrement(ref _queued); Spool(op); - } } private async Task RunAsync() @@ -96,10 +87,7 @@ public sealed class Writer } while (_queue.Reader.TryRead(out var op)) - { - Interlocked.Decrement(ref _queued); await HandleAsync(op, token); - } bool more; try diff --git a/release.sh b/release.sh index 1ee7009..b190334 100755 --- a/release.sh +++ b/release.sh @@ -22,13 +22,8 @@ set -euo pipefail FORGEJO_URL="https://git.zio.sh" OWNER="cs2" REPO="PlaytimeHistory" -# 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 +# 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. FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" # Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj @@ -49,7 +44,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 exit 1 fi for tool in podman jq curl git; do