Compare commits

..

4 commits
v0.1.2 ... main

7 changed files with 202 additions and 24 deletions

View file

@ -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}");
}

30
Migrations/002_Events.sql Normal file
View file

@ -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;

14
Ops.cs
View file

@ -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;

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.2";
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";
@ -50,7 +52,10 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
{
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;
@ -89,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();
@ -182,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;
@ -205,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++)
{
@ -220,7 +231,10 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
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);
@ -247,7 +261,8 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
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, 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));
@ -284,7 +299,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
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, InWarmup());
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 +327,42 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
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. 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 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 || 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);
}
private Geo.Location? LookupLocation(string? address)
@ -426,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)
{
@ -440,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++;

View file

@ -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_<skin>`), `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

View file

@ -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

View file

@ -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