Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2576a8cc37 | |||
| bba0471f20 | |||
| 905acd8f1d | |||
| a02d8ee222 | |||
| d06e240417 |
7 changed files with 215 additions and 26 deletions
14
Database.cs
14
Database.cs
|
|
@ -155,6 +155,20 @@ public sealed class Database
|
|||
("@max", o.MaxPlayers), ("@map", Clip(o.Map, 64)));
|
||||
break;
|
||||
|
||||
case EventRecord o:
|
||||
await Exec(connection, transaction, token, """
|
||||
INSERT IGNORE INTO pt_events
|
||||
(event_key, server_id, session_key, steamid64, kind, at, map, team, weapon, headshot, noscope,
|
||||
thrusmoke, wallbang, blind, victim_steamid64, victim_bot, site)
|
||||
VALUES (@key, @server, @session, @steamid, @kind, @at, @map, @team, @weapon, @headshot, @noscope,
|
||||
@thrusmoke, @wallbang, @blind, @victim, @victimBot, @site)
|
||||
""", ("@key", o.EventKey), ("@server", o.ServerId), ("@session", o.SessionKey), ("@steamid", o.SteamId),
|
||||
("@kind", (int)o.Kind), ("@at", o.At), ("@map", Clip(o.Map, 64)), ("@team", o.Team),
|
||||
("@weapon", Clip(o.Weapon, 64)), ("@headshot", o.Headshot), ("@noscope", o.Noscope),
|
||||
("@thrusmoke", o.Thrusmoke), ("@wallbang", o.Wallbang), ("@blind", o.Blind),
|
||||
("@victim", o.VictimSteamId), ("@victimBot", o.VictimBot), ("@site", o.Site));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unknown op {op.GetType().Name}");
|
||||
}
|
||||
|
|
|
|||
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(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;
|
||||
|
|
|
|||
|
|
@ -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.1";
|
||||
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";
|
||||
|
||||
|
|
@ -35,6 +37,9 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
|||
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;
|
||||
|
||||
|
|
@ -47,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;
|
||||
|
|
@ -86,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();
|
||||
|
||||
|
|
@ -115,7 +125,12 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
|||
ServerSeenNow();
|
||||
|
||||
AddTimer(1.0f, Reconcile, TimerFlags.REPEAT);
|
||||
AddTimer(Config.HeartbeatSeconds, () => _writer.Enqueue(new Heartbeat(Config.ServerId, Now())), TimerFlags.REPEAT);
|
||||
AddTimer(Config.HeartbeatSeconds, () =>
|
||||
{
|
||||
_writer.Enqueue(new Heartbeat(Config.ServerId, Now()));
|
||||
if (_mapRunning)
|
||||
ServerSeenNow(onlyIfChanged: true);
|
||||
}, TimerFlags.REPEAT);
|
||||
AddTimer(Config.PingSampleIntervalSeconds, SamplePing, TimerFlags.REPEAT);
|
||||
if (Config.OnlineSnapshotIntervalSeconds > 0)
|
||||
AddTimer(Config.OnlineSnapshotIntervalSeconds, TakeSnapshot, TimerFlags.REPEAT);
|
||||
|
|
@ -174,13 +189,19 @@ 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;
|
||||
|
||||
private void ServerSeenNow()
|
||||
private void ServerSeenNow(bool onlyIfChanged = false)
|
||||
{
|
||||
var hostname = ConVar.Find("hostname")?.StringValue ?? "";
|
||||
if (onlyIfChanged && hostname == _sentHostname)
|
||||
return;
|
||||
_sentHostname = hostname;
|
||||
_writer?.Enqueue(new ServerSeen(Config.ServerId, Config.PublicAddress, hostname, Now()));
|
||||
}
|
||||
|
||||
|
|
@ -194,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++)
|
||||
{
|
||||
|
|
@ -209,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);
|
||||
|
|
@ -236,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));
|
||||
|
|
@ -273,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;
|
||||
|
|
@ -298,25 +326,43 @@ public class PlaytimeHistory : BasePlugin, IPluginConfig<PlaytimeHistoryConfig>
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
catch (Exception)
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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)
|
||||
|
|
@ -415,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)
|
||||
{
|
||||
|
|
@ -429,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++;
|
||||
|
|
|
|||
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,
|
||||
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
|
||||
|
|
|
|||
16
Writer.cs
16
Writer.cs
|
|
@ -38,7 +38,9 @@ public sealed class Writer
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
public int Queued => _queue.Reader.Count;
|
||||
// Kept here: a single-reader unbounded channel can't count itself (Reader.Count throws).
|
||||
private int _queued;
|
||||
public int Queued => Volatile.Read(ref _queued);
|
||||
public int Spooled => _spooled;
|
||||
public bool Migrated => Volatile.Read(ref _migrated);
|
||||
public bool Failing => Volatile.Read(ref _failing);
|
||||
|
|
@ -48,7 +50,11 @@ public sealed class Writer
|
|||
public void Start() => _loop = Task.Run(RunAsync);
|
||||
|
||||
// From the game thread. Never blocks.
|
||||
public void Enqueue(Op op) => _queue.Writer.TryWrite(op);
|
||||
public void Enqueue(Op op)
|
||||
{
|
||||
if (_queue.Writer.TryWrite(op))
|
||||
Interlocked.Increment(ref _queued);
|
||||
}
|
||||
|
||||
// On unload: sends what's queued, waiting up to `timeout`, then spools anything left.
|
||||
public void Drain(TimeSpan timeout)
|
||||
|
|
@ -66,8 +72,11 @@ 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
|
||||
|
|
|
|||
11
release.sh
11
release.sh
|
|
@ -22,8 +22,13 @@ set -euo pipefail
|
|||
FORGEJO_URL="https://git.zio.sh"
|
||||
OWNER="cs2"
|
||||
REPO="PlaytimeHistory"
|
||||
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
|
||||
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
|
||||
# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the
|
||||
# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their
|
||||
# repos, mode 600). Don't commit a real token.
|
||||
TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token"
|
||||
if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then
|
||||
FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
|
||||
fi
|
||||
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
|
||||
|
||||
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
|
||||
|
|
@ -44,7 +49,7 @@ if [[ -z "$TAG" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
|
||||
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
|
||||
echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2
|
||||
exit 1
|
||||
fi
|
||||
for tool in podman jq curl git; do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue