Compare commits
No commits in common. "main" and "v0.1.2" have entirely different histories.
7 changed files with 24 additions and 202 deletions
14
Database.cs
14
Database.cs
|
|
@ -155,20 +155,6 @@ 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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
14
Ops.cs
14
Ops.cs
|
|
@ -16,7 +16,6 @@ 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;
|
||||||
|
|
@ -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;
|
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);
|
||||||
|
|
@ -67,13 +60,6 @@ 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,16 +14,14 @@ 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). Kills, bomb plants and defuses are logged as they happen.
|
// segments with SQL (see the README). Only country and city are kept from a player's IP.
|
||||||
// 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.2.1";
|
public override string ModuleVersion => "0.1.2";
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -52,10 +50,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
{
|
{
|
||||||
public required ulong SteamId;
|
public required ulong SteamId;
|
||||||
public required string SessionKey;
|
public required string SessionKey;
|
||||||
// The map's running stats as last read, and this session's total so far (null until they've
|
public required MatchStats? Baseline;
|
||||||
// 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 string? SegmentKey;
|
||||||
public DateTime SegmentStart;
|
public DateTime SegmentStart;
|
||||||
public int Team;
|
public int Team;
|
||||||
|
|
@ -94,8 +89,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -189,9 +182,6 @@ 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;
|
||||||
|
|
@ -215,7 +205,6 @@ 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++)
|
||||||
{
|
{
|
||||||
|
|
@ -231,10 +220,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
if (tracked == null)
|
if (tracked == null)
|
||||||
OpenSession(slot, player!, now);
|
OpenSession(slot, player!, now);
|
||||||
else
|
else
|
||||||
{
|
|
||||||
UpdateState(tracked, player!, now);
|
UpdateState(tracked, player!, now);
|
||||||
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())
|
||||||
CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null);
|
CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null);
|
||||||
|
|
@ -261,8 +247,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
private void OpenSession(int slot, CCSPlayerController player, DateTime now)
|
private void OpenSession(int slot, CCSPlayerController player, DateTime now)
|
||||||
{
|
{
|
||||||
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(), Baseline = ReadStats(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));
|
||||||
|
|
@ -299,9 +284,7 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
if (tracked.SegmentKey != null)
|
if (tracked.SegmentKey != null)
|
||||||
_writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime));
|
_writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime));
|
||||||
|
|
||||||
if (player != null)
|
var stats = player != null ? Delta(ReadStats(player), tracked.Baseline) : null;
|
||||||
SampleStats(tracked, player, InWarmup());
|
|
||||||
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)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -326,43 +309,25 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MatchStats? ReadStats(CCSPlayerController player)
|
private static MatchStats? ReadStats(CCSPlayerController player)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var stats = player.ActionTrackingServices?.MatchStats;
|
var stats = player.ActionTrackingServices?.MatchStats;
|
||||||
if (stats == null)
|
if (stats == null)
|
||||||
return null;
|
return null;
|
||||||
return new MatchStats(stats.Kills, stats.Deaths, stats.Assists, stats.HeadShotKills, stats.Damage, player.MVPs, player.Score);
|
return new MatchStats(stats.Kills, stats.Deaths, stats.Assists, stats.HeadShotKills, stats.Damage, player.MVPs, player.Score);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds what the map's running stats gained since the last read to the session's total. The first
|
// This session's share of the map's running totals. Totals that went down were reset, so they
|
||||||
// read is only the starting point: what's there already happened before this session. Kills and
|
// count from zero.
|
||||||
// score can go down (a teamkill), but deaths and damage only go down when the game resets the
|
private static MatchStats? Delta(MatchStats? now, MatchStats? baseline)
|
||||||
// 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)
|
if (now == null)
|
||||||
return;
|
return null;
|
||||||
var last = tracked.LastStats;
|
if (baseline == null)
|
||||||
tracked.LastStats = now;
|
return now;
|
||||||
if (last == null || warmup)
|
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),
|
||||||
tracked.Stats ??= new MatchStats(0, 0, 0, 0, 0, 0, 0);
|
D(now.Headshots, baseline.Headshots), D(now.Damage, baseline.Damage), D(now.Mvp, baseline.Mvp),
|
||||||
return;
|
D(now.Score, baseline.Score));
|
||||||
}
|
|
||||||
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)
|
private Geo.Location? LookupLocation(string? address)
|
||||||
|
|
@ -461,49 +426,9 @@ 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)
|
||||||
{
|
{
|
||||||
|
|
@ -515,8 +440,6 @@ 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,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,
|
- **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 as playtime like any other time, but not toward match stats (kills, deaths, assists,
|
Warmup counts like any other time. Bots and SourceTV are ignored.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -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.
|
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
|
||||||
|
|
@ -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_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
|
||||||
|
|
@ -190,22 +180,6 @@ 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,9 +38,7 @@ public sealed class Writer
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept here: a single-reader unbounded channel can't count itself (Reader.Count throws).
|
public int Queued => _queue.Reader.Count;
|
||||||
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);
|
||||||
|
|
@ -50,11 +48,7 @@ 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)
|
public void Enqueue(Op op) => _queue.Writer.TryWrite(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)
|
||||||
|
|
@ -72,11 +66,8 @@ 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()
|
||||||
{
|
{
|
||||||
|
|
@ -96,10 +87,7 @@ 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,13 +22,8 @@ set -euo pipefail
|
||||||
FORGEJO_URL="https://git.zio.sh"
|
FORGEJO_URL="https://git.zio.sh"
|
||||||
OWNER="cs2"
|
OWNER="cs2"
|
||||||
REPO="PlaytimeHistory"
|
REPO="PlaytimeHistory"
|
||||||
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
|
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
|
||||||
# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their
|
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
|
||||||
# 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
|
||||||
|
|
@ -49,7 +44,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 (export it, or put it in $TOKEN_FILE) first." >&2
|
echo "Set FORGEJO_TOKEN (edit release.sh or export it) 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