Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2576a8cc37 | |||
| bba0471f20 | |||
| 905acd8f1d |
7 changed files with 166 additions and 14 deletions
14
Database.cs
14
Database.cs
|
|
@ -155,6 +155,20 @@ public sealed class Database
|
||||||
("@max", o.MaxPlayers), ("@map", Clip(o.Map, 64)));
|
("@max", o.MaxPlayers), ("@map", Clip(o.Map, 64)));
|
||||||
break;
|
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:
|
default:
|
||||||
throw new ArgumentException($"Unknown op {op.GetType().Name}");
|
throw new ArgumentException($"Unknown op {op.GetType().Name}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
Migrations/002_Events.sql
Normal file
30
Migrations/002_Events.sql
Normal 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
14
Ops.cs
|
|
@ -16,6 +16,7 @@ namespace PlaytimeHistory;
|
||||||
[JsonDerivedType(typeof(SegmentClose), "segment_close")]
|
[JsonDerivedType(typeof(SegmentClose), "segment_close")]
|
||||||
[JsonDerivedType(typeof(Heartbeat), "heartbeat")]
|
[JsonDerivedType(typeof(Heartbeat), "heartbeat")]
|
||||||
[JsonDerivedType(typeof(OnlineSnapshot), "online_snapshot")]
|
[JsonDerivedType(typeof(OnlineSnapshot), "online_snapshot")]
|
||||||
|
[JsonDerivedType(typeof(EventRecord), "event")]
|
||||||
public abstract record Op;
|
public abstract record Op;
|
||||||
|
|
||||||
public sealed record ServerSeen(int ServerId, string Address, string Hostname, DateTime At) : 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;
|
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 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);
|
public sealed record PingStats(int Avg, int Min, int Max, int Samples);
|
||||||
|
|
@ -60,6 +67,13 @@ public enum EndKind
|
||||||
Stale = 4,
|
Stale = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum EventKind
|
||||||
|
{
|
||||||
|
Kill = 1,
|
||||||
|
BombPlanted = 2,
|
||||||
|
BombDefused = 3,
|
||||||
|
}
|
||||||
|
|
||||||
public static class Teams
|
public static class Teams
|
||||||
{
|
{
|
||||||
public const int None = 0;
|
public const int None = 0;
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,16 @@ namespace PlaytimeHistory;
|
||||||
// Records every player's time on the server in its own MySQL database: sessions (join to leave, or
|
// 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
|
// 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
|
// 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
|
// 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.
|
// Writer; command lookups run in Task.Run and hand their answer back with Server.NextFrame.
|
||||||
public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
{
|
{
|
||||||
public override string ModuleName => "PlaytimeHistory";
|
public override string ModuleName => "PlaytimeHistory";
|
||||||
public override string ModuleVersion => "0.1.3";
|
public override string ModuleVersion => "0.2.1";
|
||||||
public override string ModuleAuthor => "astra";
|
public override string ModuleAuthor => "astra";
|
||||||
public override string ModuleDescription => "Records playtime per session, team and alive/dead state";
|
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<EventPlayerDeath>(OnPlayerDeath);
|
||||||
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect, HookMode.Pre);
|
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect, HookMode.Pre);
|
||||||
RegisterEventHandler<EventRoundEnd>(OnRoundEnd);
|
RegisterEventHandler<EventRoundEnd>(OnRoundEnd);
|
||||||
|
RegisterEventHandler<EventBombPlanted>(OnBombPlanted);
|
||||||
|
RegisterEventHandler<EventBombDefused>(OnBombDefused);
|
||||||
|
|
||||||
OpenGeo();
|
OpenGeo();
|
||||||
|
|
||||||
|
|
@ -185,6 +189,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
|
|
||||||
private static string NewKey() => Guid.NewGuid().ToString("N");
|
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) =>
|
private static bool Trackable(CCSPlayerController? player) =>
|
||||||
player is { IsValid: true, IsBot: false, IsHLTV: false } &&
|
player is { IsValid: true, IsBot: false, IsHLTV: false } &&
|
||||||
player.Connected == PlayerConnectedState.Connected && player.SteamID != 0;
|
player.Connected == PlayerConnectedState.Connected && player.SteamID != 0;
|
||||||
|
|
@ -208,6 +215,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
if (_writer == null || !_mapRunning)
|
if (_writer == null || !_mapRunning)
|
||||||
return;
|
return;
|
||||||
var now = Now();
|
var now = Now();
|
||||||
|
var warmup = InWarmup();
|
||||||
var seen = new HashSet<int>();
|
var seen = new HashSet<int>();
|
||||||
for (var slot = 0; slot < Server.MaxPlayers; slot++)
|
for (var slot = 0; slot < Server.MaxPlayers; slot++)
|
||||||
{
|
{
|
||||||
|
|
@ -225,7 +233,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
UpdateState(tracked, player!, now);
|
UpdateState(tracked, player!, now);
|
||||||
SampleStats(tracked, player!);
|
SampleStats(tracked, player!, warmup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList())
|
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 location = LookupLocation(player.IpAddress);
|
||||||
var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey() };
|
var tracked = new Tracked { SteamId = player.SteamID, SessionKey = NewKey() };
|
||||||
SampleStats(tracked, player);
|
SampleStats(tracked, player, InWarmup());
|
||||||
_tracked[slot] = tracked;
|
_tracked[slot] = tracked;
|
||||||
_writer!.Enqueue(new SessionOpen(tracked.SessionKey, tracked.SteamId, Config.ServerId, player.PlayerName, now, _map,
|
_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));
|
_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));
|
_writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime));
|
||||||
|
|
||||||
if (player != null)
|
if (player != null)
|
||||||
SampleStats(tracked, player);
|
SampleStats(tracked, player, InWarmup());
|
||||||
var stats = tracked.Stats;
|
var stats = tracked.Stats;
|
||||||
var ping = tracked.PingSamples > 0
|
var ping = tracked.PingSamples > 0
|
||||||
? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples)
|
? 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
|
// 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
|
// 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
|
// 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.
|
// stats (the match starting after warmup, say); then all of the new values count. During warmup
|
||||||
private static void SampleStats(Tracked tracked, CCSPlayerController player)
|
// 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);
|
var now = ReadStats(player);
|
||||||
if (now == null)
|
if (now == null)
|
||||||
return;
|
return;
|
||||||
var last = tracked.LastStats;
|
var last = tracked.LastStats;
|
||||||
tracked.LastStats = now;
|
tracked.LastStats = now;
|
||||||
if (last == null)
|
if (last == null || warmup)
|
||||||
{
|
{
|
||||||
tracked.Stats ??= new MatchStats(0, 0, 0, 0, 0, 0, 0);
|
tracked.Stats ??= new MatchStats(0, 0, 0, 0, 0, 0, 0);
|
||||||
return;
|
return;
|
||||||
|
|
@ -452,9 +461,49 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
|
private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
|
||||||
{
|
{
|
||||||
UpdateNextFrame(@event.Userid);
|
UpdateNextFrame(@event.Userid);
|
||||||
|
RecordKill(@event);
|
||||||
return HookResult.Continue;
|
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.
|
// The controller and pawn have caught up with the event by the next frame.
|
||||||
private void UpdateNextFrame(CCSPlayerController? player)
|
private void UpdateNextFrame(CCSPlayerController? player)
|
||||||
{
|
{
|
||||||
|
|
@ -466,6 +515,8 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
|
|
||||||
private HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info)
|
private HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info)
|
||||||
{
|
{
|
||||||
|
if (InWarmup())
|
||||||
|
return HookResult.Continue;
|
||||||
foreach (var tracked in _tracked.Values)
|
foreach (var tracked in _tracked.Values)
|
||||||
if (Teams.Playing(tracked.Team))
|
if (Teams.Playing(tracked.Team))
|
||||||
tracked.Rounds++;
|
tracked.Rounds++;
|
||||||
|
|
|
||||||
28
README.md
28
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,
|
- **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
|
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).
|
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.
|
- 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
|
It replaces ConnectHistory. Player IPs are never stored: the IP is only used, on join, to look up a
|
||||||
country and city.
|
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
|
## 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.
|
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.
|
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
|
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,
|
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
|
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_nicknames` | Every name a SteamID has used, with first/last seen and times seen. |
|
||||||
| `pt_servers` | `id`, `address`, `hostname`, first/last seen. |
|
| `pt_servers` | `id`, `address`, `hostname`, first/last seen. |
|
||||||
| `pt_online_snapshots` | Players (humans), bots and slots online, every `OnlineSnapshotIntervalSeconds`. |
|
| `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. |
|
| `pt_migrations` | Applied migrations. |
|
||||||
|
|
||||||
## Queries
|
## 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';
|
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:
|
Who's online now:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|
|
||||||
16
Writer.cs
16
Writer.cs
|
|
@ -38,7 +38,9 @@ public sealed class Writer
|
||||||
_logger = logger;
|
_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 int Spooled => _spooled;
|
||||||
public bool Migrated => Volatile.Read(ref _migrated);
|
public bool Migrated => Volatile.Read(ref _migrated);
|
||||||
public bool Failing => Volatile.Read(ref _failing);
|
public bool Failing => Volatile.Read(ref _failing);
|
||||||
|
|
@ -48,7 +50,11 @@ public sealed class Writer
|
||||||
public void Start() => _loop = Task.Run(RunAsync);
|
public void Start() => _loop = Task.Run(RunAsync);
|
||||||
|
|
||||||
// From the game thread. Never blocks.
|
// 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.
|
// On unload: sends what's queued, waiting up to `timeout`, then spools anything left.
|
||||||
public void Drain(TimeSpan timeout)
|
public void Drain(TimeSpan timeout)
|
||||||
|
|
@ -66,7 +72,10 @@ public sealed class Writer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (_queue.Reader.TryRead(out var op))
|
while (_queue.Reader.TryRead(out var op))
|
||||||
|
{
|
||||||
|
Interlocked.Decrement(ref _queued);
|
||||||
Spool(op);
|
Spool(op);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunAsync()
|
private async Task RunAsync()
|
||||||
|
|
@ -87,7 +96,10 @@ public sealed class Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
while (_queue.Reader.TryRead(out var op))
|
while (_queue.Reader.TryRead(out var op))
|
||||||
|
{
|
||||||
|
Interlocked.Decrement(ref _queued);
|
||||||
await HandleAsync(op, token);
|
await HandleAsync(op, token);
|
||||||
|
}
|
||||||
|
|
||||||
bool more;
|
bool more;
|
||||||
try
|
try
|
||||||
|
|
|
||||||
11
release.sh
11
release.sh
|
|
@ -22,8 +22,13 @@ set -euo pipefail
|
||||||
FORGEJO_URL="https://git.zio.sh"
|
FORGEJO_URL="https://git.zio.sh"
|
||||||
OWNER="cs2"
|
OWNER="cs2"
|
||||||
REPO="PlaytimeHistory"
|
REPO="PlaytimeHistory"
|
||||||
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
|
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
|
||||||
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
|
# 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}"
|
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
|
||||||
|
|
||||||
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
|
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
for tool in podman jq curl git; do
|
for tool in podman jq curl git; do
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue