From a56dbad03af0d691dd8b3b6c7ddcd49db4170124 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 4 Oct 2026 17:45:48 +0100 Subject: [PATCH] PlaytimeHistory 0.1.0: sessions and team/alive segments in MySQL, DB-IP locations --- .gitignore | 3 + Config.cs | 81 ++++ Database.cs | 357 +++++++++++++++++ Geo.cs | 53 +++ Migrations/001_CreateTables.sql | 138 +++++++ Ops.cs | 71 ++++ PlaytimeHistory.cs | 655 ++++++++++++++++++++++++++++++++ PlaytimeHistory.csproj | 25 ++ README.md | 209 ++++++++++ Writer.cs | 275 ++++++++++++++ release.sh | 122 ++++++ 11 files changed, 1989 insertions(+) create mode 100644 .gitignore create mode 100644 Config.cs create mode 100644 Database.cs create mode 100644 Geo.cs create mode 100644 Migrations/001_CreateTables.sql create mode 100644 Ops.cs create mode 100644 PlaytimeHistory.cs create mode 100644 PlaytimeHistory.csproj create mode 100644 README.md create mode 100644 Writer.cs create mode 100755 release.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a3935e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +release-out/ diff --git a/Config.cs b/Config.cs new file mode 100644 index 0000000..3deb13e --- /dev/null +++ b/Config.cs @@ -0,0 +1,81 @@ +using System.Text.Json.Serialization; +using CounterStrikeSharp.API.Core; + +namespace PlaytimeHistory; + +// configs/plugins/PlaytimeHistory/PlaytimeHistory.json, which CSS writes with these defaults on first +// load. It holds the database password, so the plugin keeps it at mode 600. Changes need the plugin +// reloaded: css_plugins reload PlaytimeHistory. +public class PlaytimeHistoryConfig : BasePluginConfig +{ + [JsonPropertyName("Database")] + public DatabaseConfig Database { get; set; } = new(); + + // pt_servers.id. Must differ between servers writing to the same database. + [JsonPropertyName("ServerId")] + public int ServerId { get; set; } = 1; + + // "host:port" for pt_servers.address. The server can't work out its own public address, so + // nothing is stored when this is empty. + [JsonPropertyName("PublicAddress")] + public string PublicAddress { get; set; } = ""; + + // Whether time spent spectating or unassigned (team picker) counts toward pt_players.total_seconds. + // It's always recorded in pt_segments and pt_players.spec_seconds either way. + [JsonPropertyName("CountSpectatorTime")] + public bool CountSpectatorTime { get; set; } = false; + + // DB-IP's City Lite database, relative to the plugin folder. pre.sh installs it. Without it, + // sessions have no country or city. + [JsonPropertyName("GeoIpDatabase")] + public string GeoIpDatabase { get; set; } = "dbip-city-lite.mmdb"; + + [JsonPropertyName("PingSampleIntervalSeconds")] + public int PingSampleIntervalSeconds { get; set; } = 30; + + // How often players online are counted into pt_online_snapshots. 0 turns snapshots off. + [JsonPropertyName("OnlineSnapshotIntervalSeconds")] + public int OnlineSnapshotIntervalSeconds { get; set; } = 300; + + // How often open sessions and segments get heartbeat_at, which is where they're closed after a + // crash. A crash loses at most this much time. + [JsonPropertyName("HeartbeatSeconds")] + public int HeartbeatSeconds { get; set; } = 60; + + // Writes kept on disk while the database is unreachable, sent once it's back. Beyond this, new + // writes are dropped (and logged). + [JsonPropertyName("SpoolMaxEntries")] + public int SpoolMaxEntries { get; set; } = 20000; + + // css_playtime/css_lastseen about someone else need this; about yourself, nothing. + [JsonPropertyName("OtherPlayersPermission")] + public string OtherPlayersPermission { get; set; } = "@css/generic"; + + [JsonPropertyName("CommandCooldownSeconds")] + public int CommandCooldownSeconds { get; set; } = 5; + + [JsonPropertyName("LastSeenLimit")] + public int LastSeenLimit { get; set; } = 5; +} + +public class DatabaseConfig +{ + [JsonPropertyName("Host")] + public string Host { get; set; } = "127.0.0.1"; + + [JsonPropertyName("Port")] + public uint Port { get; set; } = 3306; + + [JsonPropertyName("Database")] + public string Database { get; set; } = "playtime_history"; + + [JsonPropertyName("User")] + public string User { get; set; } = "pt_plugin"; + + [JsonPropertyName("Password")] + public string Password { get; set; } = ""; + + // MySqlConnector's SslMode: None, Preferred, Required, VerifyCA or VerifyFull. + [JsonPropertyName("SslMode")] + public string SslMode { get; set; } = "None"; +} diff --git a/Database.cs b/Database.cs new file mode 100644 index 0000000..91080be --- /dev/null +++ b/Database.cs @@ -0,0 +1,357 @@ +using MySqlConnector; + +namespace PlaytimeHistory; + +// The plugin's own MySQL database (pt_* tables). Every method opens its own connection from the +// pool and runs off the game thread. Writes come only from the Writer, one at a time, in order. +public sealed class Database +{ + private const string MigrationPrefix = "PlaytimeHistory.Migrations."; + + private readonly string _connectionString; + + public Database(DatabaseConfig config) + { + if (!Enum.TryParse(config.SslMode, ignoreCase: true, out var sslMode)) + throw new ArgumentException($"Database.SslMode \"{config.SslMode}\" isn't one of None, Preferred, Required, VerifyCA, VerifyFull"); + + _connectionString = new MySqlConnectionStringBuilder + { + Server = config.Host, + Port = config.Port, + Database = config.Database, + UserID = config.User, + Password = config.Password, + SslMode = sslMode, + ConnectionTimeout = 10, + DefaultCommandTimeout = 30, + MaximumPoolSize = 5, + }.ConnectionString; + } + + // What css_playtime shows. Totals come from pt_players; the week from closed segments. + public sealed record Summary( + DateTime FirstSeen, DateTime LastSeen, int Sessions, long Total, long T, long CT, long Spec, + long Alive, long Dead, string LastNickname, long WeekPlaying, long WeekSpec); + + public sealed record RecentSession(DateTime StartedAt, DateTime? EndedAt, int? Seconds, string Map, EndKind EndKind); + + private async Task OpenAsync(CancellationToken token = default) + { + var connection = new MySqlConnection(_connectionString); + await connection.OpenAsync(token); + return connection; + } + + private static MySqlCommand Command(MySqlConnection connection, MySqlTransaction? transaction, string sql, + params (string Name, object? Value)[] parameters) + { + var command = new MySqlCommand(sql, connection, transaction); + foreach (var (name, value) in parameters) + command.Parameters.AddWithValue(name, value ?? DBNull.Value); + return command; + } + + // Applies the embedded Migrations/*.sql in name order, each once, like GroupVerify's gv_migrations. + // Returns the versions applied. + public async Task> MigrateAsync(CancellationToken token) + { + await using var connection = await OpenAsync(token); + await using (var create = Command(connection, null, """ + CREATE TABLE IF NOT EXISTS `pt_migrations` ( + `id` int NOT NULL AUTO_INCREMENT, + `version` varchar(128) NOT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """)) + await create.ExecuteNonQueryAsync(token); + + string last; + await using (var query = Command(connection, null, "SELECT version FROM pt_migrations ORDER BY id DESC LIMIT 1")) + last = (await query.ExecuteScalarAsync(token))?.ToString() ?? ""; + + var assembly = typeof(Database).Assembly; + var applied = new List(); + foreach (var resource in assembly.GetManifestResourceNames() + .Where(n => n.StartsWith(MigrationPrefix) && n.EndsWith(".sql")) + .Order(StringComparer.Ordinal)) + { + var version = resource[MigrationPrefix.Length..^".sql".Length]; + if (string.CompareOrdinal(version, last) <= 0) + continue; + + using var reader = new StreamReader(assembly.GetManifestResourceStream(resource)!); + await using (var migrate = Command(connection, null, await reader.ReadToEndAsync(token))) + await migrate.ExecuteNonQueryAsync(token); + await using (var record = Command(connection, null, "INSERT INTO pt_migrations (version) VALUES (@version)", ("@version", version))) + await record.ExecuteNonQueryAsync(token); + applied.Add(version); + } + return applied; + } + + public async Task ApplyAsync(Op op, CancellationToken token) + { + await using var connection = await OpenAsync(token); + await using var transaction = await connection.BeginTransactionAsync(token); + switch (op) + { + case ServerSeen o: + await Exec(connection, transaction, token, """ + INSERT INTO pt_servers (id, address, hostname, first_seen, last_seen) + VALUES (@id, @address, @hostname, @at, @at) + ON DUPLICATE KEY UPDATE + address = IF(VALUES(address) = '', address, VALUES(address)), + hostname = IF(VALUES(hostname) = '', hostname, VALUES(hostname)), + last_seen = GREATEST(last_seen, VALUES(last_seen)) + """, ("@id", o.ServerId), ("@address", o.Address), ("@hostname", Clip(o.Hostname, 128)), ("@at", o.At)); + break; + + case RecoverStale o: + await RecoverStaleAsync(connection, transaction, o, token); + break; + + case SessionOpen o: + await SessionOpenAsync(connection, transaction, o, token); + break; + + case SessionClose o: + await SessionCloseAsync(connection, transaction, o, token); + break; + + case SegmentOpen o: + await Exec(connection, transaction, token, """ + INSERT IGNORE INTO pt_segments + (segment_key, session_key, steamid64, server_id, map, team, alive, started_at, heartbeat_at) + VALUES (@key, @session, @steamid, @server, @map, @team, @alive, @at, @at) + """, ("@key", o.SegmentKey), ("@session", o.SessionKey), ("@steamid", o.SteamId), + ("@server", o.ServerId), ("@map", Clip(o.Map, 64)), ("@team", o.Team), ("@alive", o.Alive), ("@at", o.StartedAt)); + break; + + case SegmentRestate o: + await Exec(connection, transaction, token, + "UPDATE pt_segments SET team = @team, alive = @alive WHERE segment_key = @key AND ended_at IS NULL", + ("@key", o.SegmentKey), ("@team", o.Team), ("@alive", o.Alive)); + break; + + case SegmentClose o: + await CloseSegmentAsync(connection, transaction, o.SegmentKey, o.EndedAt, o.CountSpectatorTime, token); + break; + + case Heartbeat o: + await Exec(connection, transaction, token, + "UPDATE pt_sessions SET heartbeat_at = @at WHERE server_id = @server AND ended_at IS NULL AND heartbeat_at < @at", + ("@server", o.ServerId), ("@at", o.At)); + await Exec(connection, transaction, token, + "UPDATE pt_segments SET heartbeat_at = @at WHERE server_id = @server AND ended_at IS NULL AND heartbeat_at < @at", + ("@server", o.ServerId), ("@at", o.At)); + break; + + case OnlineSnapshot o: + await Exec(connection, transaction, token, """ + INSERT INTO pt_online_snapshots (server_id, taken_at, players, bots, max_players, map) + VALUES (@server, @at, @players, @bots, @max, @map) + """, ("@server", o.ServerId), ("@at", o.At), ("@players", o.Players), ("@bots", o.Bots), + ("@max", o.MaxPlayers), ("@map", Clip(o.Map, 64))); + break; + + default: + throw new ArgumentException($"Unknown op {op.GetType().Name}"); + } + await transaction.CommitAsync(token); + } + + private static async Task SessionOpenAsync(MySqlConnection connection, MySqlTransaction transaction, SessionOpen o, CancellationToken token) + { + var nickname = Clip(o.Nickname, 128); + var inserted = await Exec(connection, transaction, token, """ + INSERT IGNORE INTO pt_sessions + (session_key, steamid64, server_id, nickname, started_at, heartbeat_at, connect_map, + players_online, max_players, country_iso, country_name, city, plugin_version) + VALUES (@key, @steamid, @server, @nickname, @at, @at, @map, @online, @max, @iso, @country, @city, @version) + """, ("@key", o.SessionKey), ("@steamid", o.SteamId), ("@server", o.ServerId), ("@nickname", nickname), + ("@at", o.StartedAt), ("@map", Clip(o.Map, 64)), ("@online", o.PlayersOnline), ("@max", o.MaxPlayers), + ("@iso", o.CountryIso), ("@country", Clip(o.CountryName, 64)), ("@city", Clip(o.City, 128)), + ("@version", Clip(o.PluginVersion, 32))); + if (inserted == 0) + return; + + await Exec(connection, transaction, token, """ + INSERT INTO pt_players (steamid64, first_seen, last_seen, sessions_count, last_nickname, last_country, last_server_id) + VALUES (@steamid, @at, @at, 1, @nickname, @iso, @server) + ON DUPLICATE KEY UPDATE + last_seen = GREATEST(last_seen, VALUES(last_seen)), + sessions_count = sessions_count + 1, + last_nickname = VALUES(last_nickname), + last_country = COALESCE(VALUES(last_country), last_country), + last_server_id = VALUES(last_server_id) + """, ("@steamid", o.SteamId), ("@at", o.StartedAt), ("@nickname", nickname), ("@iso", o.CountryIso), ("@server", o.ServerId)); + + await Exec(connection, transaction, token, """ + INSERT INTO pt_nicknames (steamid64, nickname, first_seen, last_seen, times_seen) + VALUES (@steamid, @nickname, @at, @at, 1) + ON DUPLICATE KEY UPDATE last_seen = GREATEST(last_seen, VALUES(last_seen)), times_seen = times_seen + 1 + """, ("@steamid", o.SteamId), ("@nickname", nickname), ("@at", o.StartedAt)); + } + + private static async Task SessionCloseAsync(MySqlConnection connection, MySqlTransaction transaction, SessionClose o, CancellationToken token) + { + var s = o.Stats; + var p = o.Ping; + var closed = await Exec(connection, transaction, token, """ + UPDATE pt_sessions SET + ended_at = @at, heartbeat_at = @at, + duration_seconds = GREATEST(TIMESTAMPDIFF(SECOND, started_at, @at), 0), + end_kind = @kind, disconnect_map = @map, disconnect_reason = @reason, disconnect_reason_name = @reasonName, + kills = @kills, deaths = @deaths, assists = @assists, headshots = @headshots, damage = @damage, + mvp = @mvp, score = @score, rounds_played = @rounds, + ping_avg = @pingAvg, ping_min = @pingMin, ping_max = @pingMax, ping_samples = @pingSamples + WHERE session_key = @key AND ended_at IS NULL + """, ("@key", o.SessionKey), ("@at", o.EndedAt), ("@kind", (int)o.EndKind), ("@map", Clip(o.Map, 64)), + ("@reason", o.DisconnectReason), ("@reasonName", Clip(o.DisconnectReasonName, 64)), + ("@kills", s?.Kills), ("@deaths", s?.Deaths), ("@assists", s?.Assists), ("@headshots", s?.Headshots), + ("@damage", s?.Damage), ("@mvp", s?.Mvp), ("@score", s?.Score), ("@rounds", o.RoundsPlayed), + ("@pingAvg", p?.Avg), ("@pingMin", p?.Min), ("@pingMax", p?.Max), ("@pingSamples", p?.Samples)); + if (closed == 0) + return; + + await Exec(connection, transaction, token, + "UPDATE pt_players SET last_seen = GREATEST(last_seen, @at) WHERE steamid64 = @steamid", + ("@steamid", o.SteamId), ("@at", o.EndedAt)); + } + + // Closes a segment that's still open and adds its time to the player's totals, together. + private static async Task CloseSegmentAsync(MySqlConnection connection, MySqlTransaction transaction, + string segmentKey, DateTime endedAt, bool countSpectatorTime, CancellationToken token) + { + ulong steamId; + int team; + bool? alive; + DateTime startedAt; + await using (var select = Command(connection, transaction, + "SELECT steamid64, team, alive, started_at FROM pt_segments WHERE segment_key = @key AND ended_at IS NULL FOR UPDATE", + ("@key", segmentKey))) + await using (var reader = await select.ExecuteReaderAsync(token)) + { + if (!await reader.ReadAsync(token)) + return; + steamId = reader.GetUInt64(0); + team = reader.GetInt32(1); + alive = reader.IsDBNull(2) ? null : reader.GetInt32(2) != 0; + startedAt = reader.GetDateTime(3); + } + + var seconds = Math.Max(0L, (long)(endedAt - startedAt).TotalSeconds); + await Exec(connection, transaction, token, + "UPDATE pt_segments SET ended_at = @at, heartbeat_at = @at, seconds = @seconds WHERE segment_key = @key", + ("@key", segmentKey), ("@at", endedAt), ("@seconds", seconds)); + + var playing = Teams.Playing(team); + await Exec(connection, transaction, token, """ + UPDATE pt_players SET + total_seconds = total_seconds + @total, + t_seconds = t_seconds + @t, + ct_seconds = ct_seconds + @ct, + spec_seconds = spec_seconds + @spec, + alive_seconds = alive_seconds + @alive, + dead_seconds = dead_seconds + @dead + WHERE steamid64 = @steamid + """, ("@steamid", steamId), + ("@total", playing || countSpectatorTime ? seconds : 0), + ("@t", team == Teams.T ? seconds : 0), + ("@ct", team == Teams.CT ? seconds : 0), + ("@spec", playing ? 0 : seconds), + ("@alive", playing && alive == true ? seconds : 0), + ("@dead", playing && alive != true ? seconds : 0)); + } + + private static async Task RecoverStaleAsync(MySqlConnection connection, MySqlTransaction transaction, RecoverStale o, CancellationToken token) + { + var open = new List<(string Key, DateTime Heartbeat)>(); + await using (var select = Command(connection, transaction, + "SELECT segment_key, heartbeat_at FROM pt_segments WHERE server_id = @server AND ended_at IS NULL", + ("@server", o.ServerId))) + await using (var reader = await select.ExecuteReaderAsync(token)) + while (await reader.ReadAsync(token)) + open.Add((reader.GetString(0), reader.GetDateTime(1))); + + foreach (var (key, heartbeat) in open) + await CloseSegmentAsync(connection, transaction, key, heartbeat, o.CountSpectatorTime, token); + + await Exec(connection, transaction, token, """ + UPDATE pt_players p JOIN pt_sessions s ON s.steamid64 = p.steamid64 + SET p.last_seen = GREATEST(p.last_seen, s.heartbeat_at) + WHERE s.server_id = @server AND s.ended_at IS NULL + """, ("@server", o.ServerId)); + await Exec(connection, transaction, token, """ + UPDATE pt_sessions SET + ended_at = heartbeat_at, + duration_seconds = GREATEST(TIMESTAMPDIFF(SECOND, started_at, heartbeat_at), 0), + end_kind = @kind + WHERE server_id = @server AND ended_at IS NULL + """, ("@server", o.ServerId), ("@kind", (int)EndKind.Stale)); + } + + public async Task GetSummaryAsync(ulong steamId, CancellationToken token) + { + await using var connection = await OpenAsync(token); + Summary? summary = null; + await using (var select = Command(connection, null, """ + SELECT first_seen, last_seen, sessions_count, total_seconds, t_seconds, ct_seconds, + spec_seconds, alive_seconds, dead_seconds, last_nickname + FROM pt_players WHERE steamid64 = @steamid + """, ("@steamid", steamId))) + await using (var reader = await select.ExecuteReaderAsync(token)) + { + if (!await reader.ReadAsync(token)) + return null; + summary = new Summary(reader.GetDateTime(0), reader.GetDateTime(1), reader.GetInt32(2), reader.GetInt64(3), + reader.GetInt64(4), reader.GetInt64(5), reader.GetInt64(6), reader.GetInt64(7), reader.GetInt64(8), + reader.GetString(9), 0, 0); + } + + await using (var week = Command(connection, null, """ + SELECT COALESCE(SUM(CASE WHEN team IN (2, 3) THEN seconds END), 0), + COALESCE(SUM(CASE WHEN team IN (0, 1) THEN seconds END), 0) + FROM pt_segments + WHERE steamid64 = @steamid AND ended_at IS NOT NULL AND started_at >= UTC_TIMESTAMP() - INTERVAL 7 DAY + """, ("@steamid", steamId))) + await using (var reader = await week.ExecuteReaderAsync(token)) + { + if (await reader.ReadAsync(token)) + summary = summary with { WeekPlaying = reader.GetInt64(0), WeekSpec = reader.GetInt64(1) }; + } + return summary; + } + + public async Task> GetRecentSessionsAsync(ulong steamId, int limit, CancellationToken token) + { + await using var connection = await OpenAsync(token); + await using var select = Command(connection, null, """ + SELECT started_at, ended_at, duration_seconds, connect_map, end_kind + FROM pt_sessions WHERE steamid64 = @steamid + ORDER BY started_at DESC LIMIT @limit + """, ("@steamid", steamId), ("@limit", limit)); + await using var reader = await select.ExecuteReaderAsync(token); + var sessions = new List(); + while (await reader.ReadAsync(token)) + sessions.Add(new RecentSession( + reader.GetDateTime(0), + reader.IsDBNull(1) ? null : reader.GetDateTime(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + reader.GetString(3), + (EndKind)reader.GetInt32(4))); + return sessions; + } + + private static async Task Exec(MySqlConnection connection, MySqlTransaction transaction, CancellationToken token, + string sql, params (string Name, object? Value)[] parameters) + { + await using var command = Command(connection, transaction, sql, parameters); + return await command.ExecuteNonQueryAsync(token); + } + + [return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(value))] + private static string? Clip(string? value, int length) => + value == null || value.Length <= length ? value : value[..length]; +} diff --git a/Geo.cs b/Geo.cs new file mode 100644 index 0000000..4dfa83b --- /dev/null +++ b/Geo.cs @@ -0,0 +1,53 @@ +using System.Net; +using MaxMind.Db; + +namespace PlaytimeHistory; + +// Country and city for an IP, from DB-IP's City Lite database (MaxMind DB format, CC BY 4.0: +// "IP Geolocation by DB-IP", https://db-ip.com). Only the result is kept; the IP never is. +public sealed class Geo : IDisposable +{ + public sealed record Location(string? CountryIso, string? CountryName, string? City); + + private readonly Reader _reader; + + private Geo(Reader reader) => _reader = reader; + + public string DatabaseType => _reader.Metadata.DatabaseType; + public DateTime BuildDate => _reader.Metadata.BuildDate; + + // Null when the file isn't there. + public static Geo? Open(string path) => + File.Exists(path) ? new Geo(new Reader(path, FileAccessMode.MemoryMapped)) : null; + + // `address` is "ip" or "ip:port", as CCSPlayerController.IpAddress gives it. + public Location? Lookup(string? address) + { + if (string.IsNullOrEmpty(address)) + return null; + var colon = address.LastIndexOf(':'); + var host = colon > 0 && address.IndexOf(':') == colon ? address[..colon] : address; + if (!IPAddress.TryParse(host, out var ip)) + return null; + + var data = _reader.Find>(ip); + if (data == null) + return null; + + string? iso = null, country = null, city = null; + if (data.TryGetValue("country", out var c) && c is Dictionary countryData) + { + iso = countryData.TryGetValue("iso_code", out var code) ? code as string : null; + country = EnglishName(countryData); + } + if (data.TryGetValue("city", out var ct) && ct is Dictionary cityData) + city = EnglishName(cityData); + return iso == null && city == null ? null : new Location(iso, country, city); + } + + private static string? EnglishName(Dictionary data) => + data.TryGetValue("names", out var n) && n is Dictionary names && + names.TryGetValue("en", out var en) ? en as string : null; + + public void Dispose() => _reader.Dispose(); +} diff --git a/Migrations/001_CreateTables.sql b/Migrations/001_CreateTables.sql new file mode 100644 index 0000000..9c78147 --- /dev/null +++ b/Migrations/001_CreateTables.sql @@ -0,0 +1,138 @@ +-- All times are UTC. Tables are prefixed pt_ so they can share a database if ever needed. + +CREATE TABLE IF NOT EXISTS `pt_servers` ( + `id` int NOT NULL, + `address` varchar(64) NOT NULL DEFAULT '', + `hostname` varchar(128) NOT NULL DEFAULT '', + `first_seen` datetime NOT NULL, + `last_seen` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- One row per connection, opened on join and closed on leave or map change. +-- end_kind: 0 open, 1 disconnect, 2 map change, 3 plugin unload, 4 stale (closed at its last +-- heartbeat after the server died). +CREATE TABLE IF NOT EXISTS `pt_sessions` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `session_key` char(32) NOT NULL, + `steamid64` bigint unsigned NOT NULL, + `server_id` int NOT NULL, + `nickname` varchar(128) NOT NULL, + `started_at` datetime NOT NULL, + `ended_at` datetime NULL, + `heartbeat_at` datetime NOT NULL, + `duration_seconds` int unsigned NULL, + `end_kind` tinyint NOT NULL DEFAULT 0, + `connect_map` varchar(64) NOT NULL, + `disconnect_map` varchar(64) NULL, + `disconnect_reason` smallint NULL, + `disconnect_reason_name` varchar(64) NULL, + `players_online` smallint unsigned NOT NULL, + `max_players` smallint unsigned NOT NULL, + `country_iso` char(2) NULL, + `country_name` varchar(64) NULL, + `city` varchar(128) NULL, + `kills` int NULL, + `deaths` int NULL, + `assists` int NULL, + `headshots` int NULL, + `damage` int NULL, + `mvp` int NULL, + `score` int NULL, + `rounds_played` smallint unsigned NULL, + `ping_avg` smallint unsigned NULL, + `ping_min` smallint unsigned NULL, + `ping_max` smallint unsigned NULL, + `ping_samples` int unsigned NULL, + `plugin_version` varchar(32) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_session_key` (`session_key`), + KEY `ix_steamid_started` (`steamid64`, `started_at`), + KEY `ix_server_started` (`server_id`, `started_at`), + KEY `ix_started` (`started_at`), + KEY `ix_server_open` (`server_id`, `ended_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- A stretch of a session where the player's team and alive/dead state didn't change. +-- team: 0 unassigned, 1 spectator, 2 T, 3 CT. alive: NULL when not on T/CT. +CREATE TABLE IF NOT EXISTS `pt_segments` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `segment_key` char(32) NOT NULL, + `session_key` char(32) NOT NULL, + `steamid64` bigint unsigned NOT NULL, + `server_id` int NOT NULL, + `map` varchar(64) NOT NULL, + `team` tinyint NOT NULL, + `alive` tinyint NULL, + `started_at` datetime NOT NULL, + `ended_at` datetime NULL, + `heartbeat_at` datetime NOT NULL, + `seconds` int unsigned NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_segment_key` (`segment_key`), + KEY `ix_steamid_started` (`steamid64`, `started_at`), + KEY `ix_started` (`started_at`), + KEY `ix_session` (`session_key`), + KEY `ix_server_open` (`server_id`, `ended_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Running totals, added to in the same transaction that closes a segment or session. +-- spec_seconds covers both spectator and unassigned. total_seconds includes it only when the +-- plugin's CountSpectatorTime is on. +CREATE TABLE IF NOT EXISTS `pt_players` ( + `steamid64` bigint unsigned NOT NULL, + `first_seen` datetime NOT NULL, + `last_seen` datetime NOT NULL, + `sessions_count` int unsigned NOT NULL DEFAULT 0, + `total_seconds` bigint unsigned NOT NULL DEFAULT 0, + `t_seconds` bigint unsigned NOT NULL DEFAULT 0, + `ct_seconds` bigint unsigned NOT NULL DEFAULT 0, + `spec_seconds` bigint unsigned NOT NULL DEFAULT 0, + `alive_seconds` bigint unsigned NOT NULL DEFAULT 0, + `dead_seconds` bigint unsigned NOT NULL DEFAULT 0, + `last_nickname` varchar(128) NOT NULL, + `last_country` char(2) NULL, + `last_server_id` int NOT NULL, + PRIMARY KEY (`steamid64`), + KEY `ix_total` (`total_seconds`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `pt_nicknames` ( + `steamid64` bigint unsigned NOT NULL, + `nickname` varchar(128) NOT NULL, + `first_seen` datetime NOT NULL, + `last_seen` datetime NOT NULL, + `times_seen` int unsigned NOT NULL DEFAULT 1, + PRIMARY KEY (`steamid64`, `nickname`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `pt_online_snapshots` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `server_id` int NOT NULL, + `taken_at` datetime NOT NULL, + `players` smallint unsigned NOT NULL, + `bots` smallint unsigned NOT NULL, + `max_players` smallint unsigned NOT NULL, + `map` varchar(64) NOT NULL, + PRIMARY KEY (`id`), + KEY `ix_server_taken` (`server_id`, `taken_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Segments split at the hour boundaries: one row per segment per UTC hour it touches. Closed +-- segments only; filter on hour_start (and steamid64) when reading. +CREATE VIEW IF NOT EXISTS `pt_segments_hourly` AS +WITH RECURSIVE h AS ( + SELECT s.steamid64, s.server_id, s.map, s.team, s.alive, s.ended_at, + CAST(DATE_FORMAT(s.started_at, '%Y-%m-%d %H:00:00') AS DATETIME) AS hour_start, + s.started_at AS piece_start + FROM pt_segments s + WHERE s.ended_at IS NOT NULL AND s.ended_at > s.started_at + UNION ALL + SELECT steamid64, server_id, map, team, alive, ended_at, + hour_start + INTERVAL 1 HOUR, hour_start + INTERVAL 1 HOUR + FROM h + WHERE hour_start + INTERVAL 1 HOUR < ended_at +) +SELECT steamid64, server_id, map, team, alive, hour_start, + TIMESTAMPDIFF(SECOND, piece_start, LEAST(ended_at, hour_start + INTERVAL 1 HOUR)) AS seconds +FROM h; diff --git a/Ops.cs b/Ops.cs new file mode 100644 index 0000000..a6c833c --- /dev/null +++ b/Ops.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace PlaytimeHistory; + +// Every database write, as data, so the writer can run them in order off the game thread and keep +// them in the spool file while the database is down. Each one is safe to run twice: inserts are +// keyed (session_key, segment_key) and closes only touch rows still open, so a write that's sent +// again after a lost reply changes nothing. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "op")] +[JsonDerivedType(typeof(ServerSeen), "server_seen")] +[JsonDerivedType(typeof(RecoverStale), "recover_stale")] +[JsonDerivedType(typeof(SessionOpen), "session_open")] +[JsonDerivedType(typeof(SessionClose), "session_close")] +[JsonDerivedType(typeof(SegmentOpen), "segment_open")] +[JsonDerivedType(typeof(SegmentRestate), "segment_restate")] +[JsonDerivedType(typeof(SegmentClose), "segment_close")] +[JsonDerivedType(typeof(Heartbeat), "heartbeat")] +[JsonDerivedType(typeof(OnlineSnapshot), "online_snapshot")] +public abstract record Op; + +public sealed record ServerSeen(int ServerId, string Address, string Hostname, DateTime At) : Op; + +// Closes whatever this server left open (a crash, or a kill without an unload) at its last heartbeat. +public sealed record RecoverStale(int ServerId, bool CountSpectatorTime) : Op; + +public sealed record SessionOpen( + string SessionKey, ulong SteamId, int ServerId, string Nickname, DateTime StartedAt, string Map, + int PlayersOnline, int MaxPlayers, string? CountryIso, string? CountryName, string? City, + string PluginVersion) : Op; + +public sealed record SessionClose( + string SessionKey, ulong SteamId, DateTime EndedAt, EndKind EndKind, string Map, + int? DisconnectReason, string? DisconnectReasonName, MatchStats? Stats, int RoundsPlayed, + PingStats? Ping) : Op; + +public sealed record SegmentOpen( + string SegmentKey, string SessionKey, ulong SteamId, int ServerId, string Map, int Team, bool? Alive, + DateTime StartedAt) : Op; + +// The state changed within the second the segment started: change it instead of leaving a +// zero-length segment behind. +public sealed record SegmentRestate(string SegmentKey, int Team, bool? Alive) : Op; + +public sealed record SegmentClose(string SegmentKey, DateTime EndedAt, bool CountSpectatorTime) : Op; + +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 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 enum EndKind +{ + Open = 0, + Disconnect = 1, + MapChange = 2, + Unload = 3, + Stale = 4, +} + +public static class Teams +{ + public const int None = 0; + public const int Spectator = 1; + public const int T = 2; + public const int CT = 3; + + public static bool Playing(int team) => team is T or CT; +} diff --git a/PlaytimeHistory.cs b/PlaytimeHistory.cs new file mode 100644 index 0000000..75cb5e2 --- /dev/null +++ b/PlaytimeHistory.cs @@ -0,0 +1,655 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Admin; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Timers; +using CounterStrikeSharp.API.Modules.Utils; +using CounterStrikeSharp.API.ValveConstants.Protobuf; +using Microsoft.Extensions.Logging; + +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. +// +// Threads: every field below is only touched on the game thread. Database writes are queued to the +// Writer; command lookups run in Task.Run and hand their answer back with Server.NextFrame. +public class PlaytimeHistory : BasePlugin, IPluginConfig +{ + public override string ModuleName => "PlaytimeHistory"; + public override string ModuleVersion => "0.1.0"; + public override string ModuleAuthor => "astra"; + public override string ModuleDescription => "Records playtime per session, team and alive/dead state"; + + private const string StatusPermission = "@css/root"; + + public PlaytimeHistoryConfig Config { get; set; } = new(); + + private Database? _db; + private Writer? _writer; + private Geo? _geo; + private bool _geoBroken; + + private string _map = ""; + // False between a map's end and the next one's start, when nothing is opened. + private bool _mapRunning; + + // Slot -> the session open there. + private readonly Dictionary _tracked = []; + // Caller SteamID64 -> when they may use a lookup command again (UTC). + private readonly Dictionary _cooldowns = []; + + private sealed class Tracked + { + public required ulong SteamId; + public required string SessionKey; + public required MatchStats? Baseline; + public string? SegmentKey; + public DateTime SegmentStart; + public int Team; + public bool? Alive; + public int Rounds; + public long PingSum; + public int PingSamples; + public int PingMin = int.MaxValue; + public int PingMax; + } + + public void OnConfigParsed(PlaytimeHistoryConfig config) + { + config.PingSampleIntervalSeconds = Math.Max(5, config.PingSampleIntervalSeconds); + config.OnlineSnapshotIntervalSeconds = Math.Max(0, config.OnlineSnapshotIntervalSeconds); + config.HeartbeatSeconds = Math.Max(10, config.HeartbeatSeconds); + config.SpoolMaxEntries = Math.Max(0, config.SpoolMaxEntries); + config.LastSeenLimit = Math.Clamp(config.LastSeenLimit, 1, 20); + config.PublicAddress = config.PublicAddress.Trim(); + Config = config; + } + + public override void Load(bool hotReload) + { + ProtectConfigFile(); + + RegisterListener(OnMapStart); + RegisterListener(OnMapEnd); + RegisterListener(OnClientDisconnect); + RegisterEventHandler(OnPlayerConnectFull); + RegisterEventHandler(OnPlayerTeam); + RegisterEventHandler(OnPlayerSpawn); + RegisterEventHandler(OnPlayerDeath); + RegisterEventHandler(OnPlayerDisconnect, HookMode.Pre); + RegisterEventHandler(OnRoundEnd); + + OpenGeo(); + + if (Config.Database.Password.Length == 0) + { + Logger.LogError("Database.Password is empty in configs/plugins/PlaytimeHistory/PlaytimeHistory.json: " + + "nothing is recorded. Fill in the Database section, then css_plugins reload PlaytimeHistory"); + return; + } + try + { + _db = new Database(Config.Database); + } + catch (ArgumentException ex) + { + Logger.LogError("{Error}: nothing is recorded", ex.Message); + return; + } + _writer = new Writer(_db, Path.Combine(ModuleDirectory, "spool.jsonl"), Config.SpoolMaxEntries, Logger); + _writer.Start(); + // First, so sessions left open by a crash are closed before this run opens any. + _writer.Enqueue(new RecoverStale(Config.ServerId, Config.CountSpectatorTime)); + Logger.LogInformation("Recording to {Host}:{Port}/{Database} as {User}, server id {Id}", + Config.Database.Host, Config.Database.Port, Config.Database.Database, Config.Database.User, Config.ServerId); + + try + { + _map = Server.MapName; + } + catch (Exception) + { + _map = ""; + } + _mapRunning = _map.Length > 0; + if (_mapRunning) + ServerSeenNow(); + + AddTimer(1.0f, Reconcile, TimerFlags.REPEAT); + AddTimer(Config.HeartbeatSeconds, () => _writer.Enqueue(new Heartbeat(Config.ServerId, Now())), TimerFlags.REPEAT); + AddTimer(Config.PingSampleIntervalSeconds, SamplePing, TimerFlags.REPEAT); + if (Config.OnlineSnapshotIntervalSeconds > 0) + AddTimer(Config.OnlineSnapshotIntervalSeconds, TakeSnapshot, TimerFlags.REPEAT); + } + + public override void Unload(bool hotReload) + { + if (_writer != null) + { + CloseAll(EndKind.Unload); + _writer.Drain(TimeSpan.FromSeconds(5)); + } + _geo?.Dispose(); + } + + // The config holds the database password. + private void ProtectConfigFile() + { + if (OperatingSystem.IsWindows()) + return; + var path = Path.GetFullPath(Path.Combine(ModuleDirectory, "..", "..", "configs", "plugins", ModuleName, $"{ModuleName}.json")); + try + { + if (File.Exists(path)) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) + { + Logger.LogWarning("Couldn't make {Path} readable by its owner only: {Error}", path, ex.Message); + } + } + + private void OpenGeo() + { + var path = Path.Combine(ModuleDirectory, Config.GeoIpDatabase); + try + { + _geo = Geo.Open(path); + } + catch (Exception ex) + { + Logger.LogError("Couldn't open the location database {Path}: {Error}", path, ex.Message); + return; + } + if (_geo == null) + Logger.LogWarning("No location database at {Path}: sessions get no country or city", path); + else + Logger.LogInformation("Location database {Type}, built {Date:yyyy-MM-dd}", _geo.DatabaseType, _geo.BuildDate); + } + + private static DateTime Now() + { + var now = DateTime.UtcNow; + return new DateTime(now.Ticks - now.Ticks % TimeSpan.TicksPerSecond, DateTimeKind.Utc); + } + + private static string NewKey() => Guid.NewGuid().ToString("N"); + + private static bool Trackable(CCSPlayerController? player) => + player is { IsValid: true, IsBot: false, IsHLTV: false } && + player.Connected == PlayerConnectedState.Connected && player.SteamID != 0; + + private void ServerSeenNow() + { + var hostname = ConVar.Find("hostname")?.StringValue ?? ""; + _writer?.Enqueue(new ServerSeen(Config.ServerId, Config.PublicAddress, hostname, Now())); + } + + // --- Tracking --------------------------------------------------------------------- + + // Once a second, and after the events that change state: opens sessions for players who've + // arrived, closes them for players who've gone, and moves everyone's segments along. The events + // make the times exact; this catches anything they miss. + private void Reconcile() + { + if (_writer == null || !_mapRunning) + return; + var now = Now(); + var seen = new HashSet(); + for (var slot = 0; slot < Server.MaxPlayers; slot++) + { + var player = Utilities.GetPlayerFromSlot(slot); + if (!Trackable(player)) + continue; + seen.Add(slot); + if (_tracked.TryGetValue(slot, out var tracked) && tracked.SteamId != player!.SteamID) + { + CloseSession(slot, tracked, EndKind.Disconnect, now, null, null); + tracked = null; + } + if (tracked == null) + OpenSession(slot, player!, now); + else + UpdateState(tracked, player!, now); + } + foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList()) + CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null); + } + + private void UpdateSlot(int slot) + { + if (_writer == null || !_mapRunning || !_tracked.TryGetValue(slot, out var tracked)) + return; + var player = Utilities.GetPlayerFromSlot(slot); + if (Trackable(player) && player!.SteamID == tracked.SteamId) + UpdateState(tracked, player, Now()); + } + + private static (int Team, bool? Alive) ReadState(CCSPlayerController player) + { + var team = Math.Clamp((int)player.Team, Teams.None, Teams.CT); + if (!Teams.Playing(team)) + return (team, null); + var pawn = player.PlayerPawn.Value; + return (team, pawn is { IsValid: true } && pawn.LifeState == (byte)LifeState_t.LIFE_ALIVE); + } + + 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) }; + _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)); + UpdateState(tracked, player, now); + } + + private void UpdateState(Tracked tracked, CCSPlayerController player, DateTime now) + { + var (team, alive) = ReadState(player); + if (tracked.SegmentKey != null && team == tracked.Team && alive == tracked.Alive) + return; + + if (tracked.SegmentKey != null && now <= tracked.SegmentStart) + { + _writer!.Enqueue(new SegmentRestate(tracked.SegmentKey, team, alive)); + } + else + { + if (tracked.SegmentKey != null) + _writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime)); + tracked.SegmentKey = NewKey(); + tracked.SegmentStart = now; + _writer!.Enqueue(new SegmentOpen(tracked.SegmentKey, tracked.SessionKey, tracked.SteamId, Config.ServerId, _map, + team, alive, now)); + } + tracked.Team = team; + tracked.Alive = alive; + } + + // `player` is null when they're already gone; then the session has no match stats. + private void CloseSession(int slot, Tracked tracked, EndKind kind, DateTime now, CCSPlayerController? player, int? reason) + { + _tracked.Remove(slot); + if (tracked.SegmentKey != null) + _writer!.Enqueue(new SegmentClose(tracked.SegmentKey, now, Config.CountSpectatorTime)); + + var stats = player != null ? Delta(ReadStats(player), tracked.Baseline) : null; + var ping = tracked.PingSamples > 0 + ? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples) + : null; + string? reasonName = reason is { } r + ? (Enum.IsDefined(typeof(NetworkDisconnectionReason), r) + ? ((NetworkDisconnectionReason)r).ToString().Replace("NETWORK_DISCONNECT_", "") + : $"REASON_{r}") + : null; + _writer!.Enqueue(new SessionClose(tracked.SessionKey, tracked.SteamId, now, kind, _map, reason, reasonName, + stats, tracked.Rounds, ping)); + } + + private void CloseAll(EndKind kind) + { + var now = Now(); + foreach (var (slot, tracked) in _tracked.ToList()) + { + var player = Utilities.GetPlayerFromSlot(slot); + var same = player is { IsValid: true } && player.SteamID == tracked.SteamId; + CloseSession(slot, tracked, kind, now, same ? player : null, null); + } + } + + private static MatchStats? ReadStats(CCSPlayerController player) + { + 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) + { + 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)); + } + + private Geo.Location? LookupLocation(string? address) + { + if (_geo == null || _geoBroken) + return null; + try + { + return _geo.Lookup(address); + } + catch (Exception ex) + { + // A broken file would fail for everyone; say so once. + _geoBroken = true; + Logger.LogError("Location lookups are off until the next reload: {Error}", ex.Message); + return null; + } + } + + private void SamplePing() + { + foreach (var (slot, tracked) in _tracked) + { + var player = Utilities.GetPlayerFromSlot(slot); + if (player is not { IsValid: true } || player.SteamID != tracked.SteamId) + continue; + var ping = (int)player.Ping; + if (ping <= 0) + continue; + tracked.PingSum += ping; + tracked.PingSamples++; + tracked.PingMin = Math.Min(tracked.PingMin, ping); + tracked.PingMax = Math.Max(tracked.PingMax, ping); + } + } + + private void TakeSnapshot() + { + if (_writer == null || !_mapRunning) + return; + var bots = Utilities.GetPlayers().Count(p => p.IsBot); + _writer.Enqueue(new OnlineSnapshot(Config.ServerId, Now(), _tracked.Count, bots, Server.MaxPlayers, _map)); + } + + // --- Events ----------------------------------------------------------------------- + + private void OnMapStart(string map) + { + _map = map; + _mapRunning = true; + ServerSeenNow(); + } + + private void OnMapEnd() + { + if (_writer != null) + CloseAll(EndKind.MapChange); + _mapRunning = false; + } + + private void OnClientDisconnect(int slot) + { + // Normally player_disconnect has closed it already, with the reason. + if (_writer != null && _tracked.TryGetValue(slot, out var tracked)) + CloseSession(slot, tracked, EndKind.Disconnect, Now(), null, null); + } + + private HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) + { + var player = @event.Userid; + if (_writer != null && player is { IsValid: true } && _tracked.TryGetValue(player.Slot, out var tracked) && + tracked.SteamId == player.SteamID) + CloseSession(player.Slot, tracked, EndKind.Disconnect, Now(), player, @event.Reason); + return HookResult.Continue; + } + + private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) + { + Server.NextFrame(Reconcile); + return HookResult.Continue; + } + + private HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) + { + if (!@event.Disconnect) + UpdateNextFrame(@event.Userid); + return HookResult.Continue; + } + + private HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) + { + UpdateNextFrame(@event.Userid); + return HookResult.Continue; + } + + private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) + { + UpdateNextFrame(@event.Userid); + return HookResult.Continue; + } + + // The controller and pawn have caught up with the event by the next frame. + private void UpdateNextFrame(CCSPlayerController? player) + { + if (player is not { IsValid: true }) + return; + var slot = player.Slot; + Server.NextFrame(() => UpdateSlot(slot)); + } + + private HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info) + { + foreach (var tracked in _tracked.Values) + if (Teams.Playing(tracked.Team)) + tracked.Rounds++; + return HookResult.Continue; + } + + // --- Commands --------------------------------------------------------------------- + + [ConsoleCommand("css_playtime", "Shows playtime: yours, or (staff) someone else's")] + [CommandHelper(usage: "[name|#userid|steamid64]")] + public void OnPlaytimeCommand(CCSPlayerController? caller, CommandInfo command) => Lookup(caller, command, sessions: false); + + [ConsoleCommand("css_lastseen", "Shows recent sessions: yours, or (staff) someone else's")] + [CommandHelper(usage: "[name|#userid|steamid64]")] + public void OnLastSeenCommand(CCSPlayerController? caller, CommandInfo command) => Lookup(caller, command, sessions: true); + + [ConsoleCommand("css_pt_status", "Shows PlaytimeHistory's database state")] + [RequiresPermissions(StatusPermission)] + public void OnStatusCommand(CCSPlayerController? caller, CommandInfo command) + { + if (_writer == null) + { + command.ReplyToCommand("[Playtime] Not recording: the database isn't configured (see the server log)."); + return; + } + var lastOk = _writer.LastOkAt is { } at ? $"{at:yyyy-MM-dd HH:mm:ss} UTC" : "never"; + command.ReplyToCommand($"[Playtime] Database: {(_writer.Failing ? "FAILING" : _writer.Migrated ? "ok" : "connecting")}, " + + $"last success {lastOk}" + (_writer.Failing && _writer.LastError != null ? $", error: {_writer.LastError}" : "")); + command.ReplyToCommand($"[Playtime] Queued writes: {_writer.Queued}, spooled: {_writer.Spooled}, open sessions: {_tracked.Count}"); + command.ReplyToCommand(_geo != null + ? $"[Playtime] Location database: {_geo.DatabaseType}, built {_geo.BuildDate:yyyy-MM-dd}{(_geoBroken ? " (lookups off after an error)" : "")}" + : "[Playtime] Location database: not loaded"); + } + + private void Lookup(CCSPlayerController? caller, CommandInfo command, bool sessions) + { + if (_db == null) + { + command.ReplyToCommand("[Playtime] Not recording: the database isn't configured."); + return; + } + + var arg = command.ArgCount > 1 ? command.ArgString.Trim() : ""; + ulong steamId; + string name; + if (arg.Length == 0) + { + if (caller == null) + { + command.ReplyToCommand($"Usage: {command.GetArg(0)} "); + return; + } + steamId = caller.SteamID; + name = caller.PlayerName; + } + else + { + if (caller != null && !AdminManager.PlayerHasPermissions(caller, Config.OtherPlayersPermission)) + { + command.ReplyToCommand("[Playtime] You can only look yourself up."); + return; + } + var error = ResolveTarget(arg, out steamId, out name); + if (error != null) + { + command.ReplyToCommand($"[Playtime] {error}"); + return; + } + } + + if (caller != null) + { + var now = DateTime.UtcNow; + if (_cooldowns.TryGetValue(caller.SteamID, out var until) && until > now) + { + command.ReplyToCommand($"[Playtime] Wait {Math.Ceiling((until - now).TotalSeconds)}s."); + return; + } + _cooldowns[caller.SteamID] = now.AddSeconds(Config.CommandCooldownSeconds); + } + + var db = _db; + var limit = Config.LastSeenLimit; + int? callerSlot = caller?.Slot; + var callerSteamId = caller?.SteamID ?? 0; + Task.Run(async () => + { + List lines; + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + if (sessions) + { + var recent = await db.GetRecentSessionsAsync(steamId, limit, timeout.Token); + await Server.NextFrameAsync(() => Reply(callerSlot, callerSteamId, DescribeSessions(name, recent))); + } + else + { + var summary = await db.GetSummaryAsync(steamId, timeout.Token); + await Server.NextFrameAsync(() => Reply(callerSlot, callerSteamId, DescribeSummary(name, steamId, summary))); + } + return; + } + catch (Exception ex) + { + Logger.LogError("Lookup for {SteamId} failed: {Error}", steamId, ex.Message); + lines = ["Couldn't read the database, try again later."]; + } + await Server.NextFrameAsync(() => Reply(callerSlot, callerSteamId, lines)); + }); + } + + // A SteamID64 (online or not), #userid, or part of an online player's name. + private static string? ResolveTarget(string arg, out ulong steamId, out string name) + { + steamId = 0; + name = arg; + if (arg.Length == 17 && ulong.TryParse(arg, out var id) && id > 76561197960265728UL) + { + steamId = id; + name = Utilities.GetPlayers().FirstOrDefault(p => p.SteamID == id)?.PlayerName ?? arg; + return null; + } + + var players = Utilities.GetPlayers().Where(p => !p.IsBot && !p.IsHLTV).ToList(); + List matches; + if (arg.StartsWith('#') && int.TryParse(arg[1..], out var userId)) + matches = players.Where(p => p.UserId == userId).ToList(); + else + { + matches = players.Where(p => string.Equals(p.PlayerName, arg, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) + matches = players.Where(p => p.PlayerName.Contains(arg, StringComparison.OrdinalIgnoreCase)).ToList(); + } + if (matches.Count == 0) + return $"No player online matches \"{arg}\". Offline players: use their SteamID64."; + if (matches.Count > 1) + return $"\"{arg}\" matches {matches.Count} players: {string.Join(", ", matches.Select(p => p.PlayerName))}."; + steamId = matches[0].SteamID; + name = matches[0].PlayerName; + return null; + } + + private List DescribeSummary(string name, ulong steamId, Database.Summary? summary) + { + // pt_players only has closed segments; add the one open now. + long t = 0, ct = 0, spec = 0, alive = 0, dead = 0; + var live = _tracked.Values.FirstOrDefault(x => x.SteamId == steamId); + if (live?.SegmentKey != null) + { + var seconds = Math.Max(0L, (long)(Now() - live.SegmentStart).TotalSeconds); + if (live.Team == Teams.T) t = seconds; + else if (live.Team == Teams.CT) ct = seconds; + else spec = seconds; + if (Teams.Playing(live.Team)) + { + if (live.Alive == true) alive = seconds; + else dead = seconds; + } + } + if (summary == null && live == null) + return [$"No playtime recorded for {name}."]; + + var s = summary ?? new Database.Summary(DateTime.UtcNow, DateTime.UtcNow, 1, 0, 0, 0, 0, 0, 0, name, 0, 0); + t += s.T; + ct += s.CT; + spec += s.Spec; + alive += s.Alive; + dead += s.Dead; + var total = t + ct + (Config.CountSpectatorTime ? spec : 0); + return + [ + $"{name}: {Duration(total)} played{(Config.CountSpectatorTime ? " (spectating included)" : "")}", + $"T {Duration(t)}, CT {Duration(ct)}, spectating {Duration(spec)}", + $"Alive {Duration(alive)}, dead {Duration(dead)}", + $"Last 7 days: {Duration(s.WeekPlaying)} on a team, {Duration(s.WeekSpec)} spectating. " + + $"{s.Sessions} session{(s.Sessions == 1 ? "" : "s")} since {s.FirstSeen:yyyy-MM-dd}", + ]; + } + + private static List DescribeSessions(string name, List sessions) + { + if (sessions.Count == 0) + return [$"No sessions recorded for {name}."]; + var lines = new List { $"{name}'s last {sessions.Count} session{(sessions.Count == 1 ? "" : "s")} (UTC):" }; + foreach (var s in sessions) + { + var length = s.EndedAt == null + ? $"{Duration((long)(DateTime.UtcNow - s.StartedAt).TotalSeconds)}, online now" + : Duration(s.Seconds ?? 0) + (s.EndKind == EndKind.Stale ? " (server stopped)" : ""); + lines.Add($"{s.StartedAt:yyyy-MM-dd HH:mm} {length} {s.Map}"); + } + return lines; + } + + private static void Reply(int? slot, ulong steamId, List lines) + { + if (slot == null) + { + foreach (var line in lines) + Server.PrintToConsole($"[Playtime] {line}"); + return; + } + var player = Utilities.GetPlayerFromSlot(slot.Value); + if (player is not { IsValid: true } || player.SteamID != steamId) + return; + foreach (var line in lines) + player.PrintToChat($" {ChatColors.Green}[Playtime]{ChatColors.Default} {line}"); + } + + private static string Duration(long seconds) + { + if (seconds < 60) + return $"{Math.Max(0, seconds)}s"; + var minutes = seconds / 60; + return minutes < 60 ? $"{minutes}m" : $"{minutes / 60}h {minutes % 60:00}m"; + } +} diff --git a/PlaytimeHistory.csproj b/PlaytimeHistory.csproj new file mode 100644 index 0000000..790b09b --- /dev/null +++ b/PlaytimeHistory.csproj @@ -0,0 +1,25 @@ + + + net10.0 + enable + enable + PlaytimeHistory + PlaytimeHistory + + + + + none + runtime + compile; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c34e31 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# PlaytimeHistory + +Records every player's time on the server in its own MySQL/MariaDB database: + +- **Sessions**: join to leave (or to a map change), with the map, disconnect reason, country and + city, match stats (kills, deaths, assists, headshots, damage, MVPs, score), rounds played and ping. +- **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). +- 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. + +## Setup + +1. Create the database and a user for the plugin, as root in MariaDB. On fr04 the cs2 container + reaches MariaDB from `10.89.0.1` (the `systemd-static` network's gateway), so that's the host: + + ```sql + CREATE DATABASE playtime_history CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE USER 'pt_plugin'@'10.89.0.1' IDENTIFIED BY ''; + GRANT SELECT, INSERT, UPDATE, CREATE, INDEX, ALTER, CREATE VIEW ON playtime_history.* TO 'pt_plugin'@'10.89.0.1'; + ``` + + It never needs `DELETE` or `DROP`. For something that only reads it (the web panel), a separate + user with `GRANT SELECT ON playtime_history.*` is enough, the hourly view included. + +2. Start the server once. The plugin writes `configs/plugins/PlaytimeHistory/PlaytimeHistory.json` + (mode 600) and records nothing until `Database.Password` is filled in: + + ```json + { + "Database": { + "Host": "10.89.0.25", + "Port": 3306, + "Database": "playtime_history", + "User": "pt_plugin", + "Password": "", + "SslMode": "None" + }, + "ServerId": 1, + "PublicAddress": "162.19.125.4:27015", + "CountSpectatorTime": false, + "GeoIpDatabase": "dbip-city-lite.mmdb", + "PingSampleIntervalSeconds": 30, + "OnlineSnapshotIntervalSeconds": 300, + "HeartbeatSeconds": 60, + "SpoolMaxEntries": 20000, + "OtherPlayersPermission": "@css/generic", + "CommandCooldownSeconds": 5, + "LastSeenLimit": 5, + "ConfigVersion": 1 + } + ``` + + `SslMode` `None` is fine on fr04, where the connection never leaves the host's podman networks. + +3. `css_plugins reload PlaytimeHistory`, then `css_pt_status` should say `Database: ok`. The tables + are created on first connect. + +| Setting | Meaning | +|---|---| +| `ServerId` | `pt_servers.id`. Must differ between servers sharing a database. | +| `PublicAddress` | Stored in `pt_servers.address`; the server can't work out its own public address. | +| `CountSpectatorTime` | Whether spectating and unassigned time counts toward `pt_players.total_seconds` and the `css_playtime` total. It's recorded either way. | +| `GeoIpDatabase` | The location database, relative to the plugin folder. Missing: no country or city. | +| `HeartbeatSeconds` | How often open sessions and segments are marked as still going. After a crash they're closed at their last heartbeat, so at most this much time is lost. | +| `SpoolMaxEntries` | Writes kept on disk while the database is down (see below). | +| `OtherPlayersPermission` | Needed to look up anyone but yourself. | + +## Commands + +| Command | Who | What | +|---|---|---| +| `css_playtime [player]` | everyone (others: `OtherPlayersPermission`) | Total, T/CT/spectating, alive/dead, last 7 days | +| `css_lastseen [player]` | everyone (others: `OtherPlayersPermission`) | Recent sessions | +| `css_pt_status` | `@css/root` | Database state, queued and spooled writes, location database | + +`[player]` is part of an online player's name, `#userid`, or a SteamID64 (online or not). From the +server console the answer to `css_playtime`/`css_lastseen` is printed to the console a moment later, +so it doesn't reach RCON; read the database for that. + +## How it records + +Every second, and right after `player_team`, `player_spawn`, `player_death` and +`player_connect_full`, the plugin reads each player's team and whether their pawn is alive. A +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. + +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 +one twice changes nothing. A write the database rejects outright (bad data) is logged and dropped. + +If the server dies with sessions open, the next start closes them at their last heartbeat with +`end_kind` 4. + +## Tables + +All times are UTC (`UTC_TIMESTAMP()`, not `NOW()`, when comparing). + +| Table | What | +|---|---| +| `pt_sessions` | One row per connection. `end_kind`: 0 open, 1 disconnect, 2 map change, 3 plugin unload, 4 stale (server died). `duration_seconds` is connected time. | +| `pt_segments` | `team` (0 unassigned, 1 spectator, 2 T, 3 CT), `alive` (1/0, NULL off T/CT), `started_at`, `ended_at`, `seconds`, `map`, `session_key`. Open while `ended_at IS NULL`. | +| `pt_segments_hourly` | View: closed segments split at UTC hour boundaries, one row per segment per hour, with `hour_start` and `seconds`. | +| `pt_players` | Totals per player: `total_seconds`, `t_seconds`, `ct_seconds`, `spec_seconds` (spectator + unassigned), `alive_seconds`, `dead_seconds`, `sessions_count`, first/last seen, last nickname and country. Only closed segments are in them. | +| `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_migrations` | Applied migrations. | + +## Queries + +Playtime per hour for one player, by state: + +```sql +SELECT hour_start, team, alive, SUM(seconds) AS seconds +FROM pt_segments_hourly +WHERE steamid64 = ? AND hour_start >= UTC_TIMESTAMP() - INTERVAL 7 DAY +GROUP BY hour_start, team, alive +ORDER BY hour_start, team, alive; +``` + +Time on a team per hour of the day, everyone, last 30 days (`team IN (0, 1)` for spectating): + +```sql +SELECT HOUR(hour_start) AS hour_utc, ROUND(SUM(seconds) / 3600, 1) AS hours +FROM pt_segments_hourly +WHERE team IN (2, 3) AND hour_start >= UTC_TIMESTAMP() - INTERVAL 30 DAY +GROUP BY HOUR(hour_start) +ORDER BY hour_utc; +``` + +The view splits every closed segment before filtering. If it gets slow, split from `pt_segments` +with the date filter inside instead: + +```sql +WITH RECURSIVE s AS ( + SELECT steamid64, team, alive, started_at AS t0, ended_at AS t1 + FROM pt_segments + WHERE ended_at IS NOT NULL AND ended_at > started_at + AND started_at >= UTC_TIMESTAMP() - INTERVAL 7 DAY +), +h AS ( + SELECT steamid64, team, alive, t1, + CAST(DATE_FORMAT(t0, '%Y-%m-%d %H:00:00') AS DATETIME) AS hour_start, t0 AS piece_start + FROM s + UNION ALL + SELECT steamid64, team, alive, t1, hour_start + INTERVAL 1 HOUR, hour_start + INTERVAL 1 HOUR + FROM h WHERE hour_start + INTERVAL 1 HOUR < t1 +) +SELECT steamid64, hour_start, team, alive, + SUM(TIMESTAMPDIFF(SECOND, piece_start, LEAST(t1, hour_start + INTERVAL 1 HOUR))) AS seconds +FROM h +GROUP BY steamid64, hour_start, team, alive; +``` + +Leaderboard, time on a team: + +```sql +SELECT last_nickname, steamid64, ROUND((t_seconds + ct_seconds) / 3600, 1) AS hours, + ROUND(alive_seconds / GREATEST(t_seconds + ct_seconds, 1) * 100) AS pct_alive +FROM pt_players +ORDER BY t_seconds + ct_seconds DESC +LIMIT 50; +``` + +T, CT, spectating, alive and dead in a date range (`pt_players` is all-time): + +```sql +SELECT SUM(IF(team = 2, seconds, 0)) AS t, SUM(IF(team = 3, seconds, 0)) AS ct, + SUM(IF(team IN (0, 1), seconds, 0)) AS spectating, + SUM(IF(alive = 1, seconds, 0)) AS alive, SUM(IF(alive = 0, seconds, 0)) AS dead +FROM pt_segments +WHERE steamid64 = ? AND ended_at IS NOT NULL + AND started_at >= '2026-10-01' AND started_at < '2026-11-01'; +``` + +Who's online now: + +```sql +SELECT s.nickname, s.steamid64, s.started_at, g.team, g.alive +FROM pt_sessions s LEFT JOIN pt_segments g ON g.session_key = s.session_key AND g.ended_at IS NULL +WHERE s.server_id = 1 AND s.ended_at IS NULL; +``` + +## Location data + +Country and city come from DB-IP's free **IP to City Lite** database +(https://db-ip.com/db/download/ip-to-city-lite), licensed CC BY 4.0. Anywhere that shows them (the +web panel, say) must credit it: `IP Geolocation by DB-IP`. + +`server/pre.sh` installs it into the plugin folder as `dbip-city-lite.mmdb` (about 60 MB to +download, 127 MB unpacked), pinned by month in `DBIP_VERSION` (`YYYY-MM`). DB-IP publishes a new one +on the 1st of each month; bump `DBIP_VERSION` by hand to update. The plugin reads it on load, so +reload or restart after a new one. + +## Releases + +`./release.sh ` (with `FORGEJO_TOKEN`) rebuilds from the committed source and publishes +`PlaytimeHistory-.tar.gz`, laid out like `game/csgo/`. The tag must be `v`. It +ships the plugin and its libraries (`MySqlConnector`, `MaxMind.Db`), never `configs/` or the +location database. `server/pre.sh` installs it (`PTH_VERSION`). + +Build locally with `../../build.sh` from this folder; output goes to `compiled/PlaytimeHistory/`. diff --git a/Writer.cs b/Writer.cs new file mode 100644 index 0000000..1ac4df3 --- /dev/null +++ b/Writer.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; +using MySqlConnector; + +namespace PlaytimeHistory; + +// Runs every database write on one background task, in the order the game thread queued them. When +// the database can't be reached, writes go to a spool file (one JSON op per line) instead, and every +// later write follows them there until the spool has been sent, so the order never changes. Migrations +// run first, retried until they work; until then everything is spooled. +public sealed class Writer +{ + private static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(15); + + private readonly Database _db; + private readonly string _spoolPath; + private readonly int _spoolMax; + private readonly ILogger _logger; + private readonly Channel _queue = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + private readonly CancellationTokenSource _stop = new(); + private Task? _loop; + + private bool _migrated; + private bool _failing; + private bool _spoolFullWarned; + // While failing, migrations and the spool are only retried from this time on (UTC). + private DateTime _retryAt; + private volatile int _spooled; + private volatile string? _lastError; + private DateTime? _lastOkAt; + + public Writer(Database db, string spoolPath, int spoolMax, ILogger logger) + { + _db = db; + _spoolPath = spoolPath; + _spoolMax = spoolMax; + _logger = logger; + } + + public int Queued => _queue.Reader.Count; + public int Spooled => _spooled; + public bool Migrated => Volatile.Read(ref _migrated); + public bool Failing => Volatile.Read(ref _failing); + public string? LastError => _lastError; + public DateTime? LastOkAt { get { lock (_stop) return _lastOkAt; } } + + public void Start() => _loop = Task.Run(RunAsync); + + // From the game thread. Never blocks. + public void Enqueue(Op op) => _queue.Writer.TryWrite(op); + + // On unload: sends what's queued, waiting up to `timeout`, then spools anything left. + public void Drain(TimeSpan timeout) + { + _queue.Writer.TryComplete(); + if (_loop == null) + return; + if (!_loop.Wait(timeout)) + { + _stop.Cancel(); + if (!_loop.Wait(TimeSpan.FromSeconds(2))) + { + _logger.LogWarning("Database writer didn't stop; {Count} queued writes are lost", Queued); + return; + } + } + while (_queue.Reader.TryRead(out var op)) + Spool(op); + } + + private async Task RunAsync() + { + _spooled = CountSpool(); + if (_spooled > 0) + _logger.LogInformation("{Count} writes waiting in the spool from before", _spooled); + + var token = _stop.Token; + while (!token.IsCancellationRequested) + { + if (!_failing || DateTime.UtcNow >= _retryAt) + { + if (!_migrated) + await TryMigrateAsync(token); + if (_migrated && _spooled > 0) + await FlushSpoolAsync(token); + } + + while (_queue.Reader.TryRead(out var op)) + await HandleAsync(op, token); + + bool more; + try + { + using var wait = CancellationTokenSource.CreateLinkedTokenSource(token); + wait.CancelAfter(RetryInterval); + more = await _queue.Reader.WaitToReadAsync(wait.Token); + } + catch (OperationCanceledException) + { + continue; + } + if (!more) + { + // Unloading: everything's been read. One last try at the spool. + if (_migrated && _spooled > 0) + await FlushSpoolAsync(token); + return; + } + } + } + + private async Task TryMigrateAsync(CancellationToken token) + { + try + { + foreach (var version in await _db.MigrateAsync(token)) + _logger.LogInformation("Migration \"{Version}\" applied", version); + Volatile.Write(ref _migrated, true); + Succeeded(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Failed(ex, "Can't reach the database to set up its tables; writes are spooled until it works"); + } + } + + private async Task HandleAsync(Op op, CancellationToken token) + { + if (!_migrated || _spooled > 0) + { + Spool(op); + return; + } + try + { + await _db.ApplyAsync(op, token); + Succeeded(); + } + catch (Exception ex) when (Transient(ex)) + { + Failed(ex, "Database write failed; spooling writes until it's back"); + Spool(op); + } + catch (Exception ex) + { + _lastError = ex.Message; + _logger.LogError(ex, "Dropped a {Op} write the database refused", op.GetType().Name); + } + } + + // Sends the spool in order. Stops at the first write that fails for a reason that may pass, and + // keeps it and the rest. + private async Task FlushSpoolAsync(CancellationToken token) + { + string[] lines; + try + { + lines = File.ReadAllLines(_spoolPath); + } + catch (FileNotFoundException) + { + _spooled = 0; + return; + } + + var sent = 0; + for (; sent < lines.Length; sent++) + { + Op? op; + try + { + op = JsonSerializer.Deserialize(lines[sent]); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Skipped an unreadable spool line"); + continue; + } + if (op == null) + continue; + + try + { + await _db.ApplyAsync(op, token); + Succeeded(); + } + catch (Exception ex) when (Transient(ex)) + { + Failed(ex, "Database write failed while sending the spool; trying again later"); + break; + } + catch (Exception ex) + { + _lastError = ex.Message; + _logger.LogError(ex, "Dropped a spooled {Op} write the database refused", op.GetType().Name); + } + } + + if (sent >= lines.Length) + { + File.Delete(_spoolPath); + _spooled = 0; + _logger.LogInformation("Sent {Count} spooled writes", lines.Length); + return; + } + var temp = _spoolPath + ".tmp"; + File.WriteAllLines(temp, lines[sent..]); + File.Move(temp, _spoolPath, overwrite: true); + _spooled = lines.Length - sent; + } + + private void Spool(Op op) + { + if (_spooled >= _spoolMax) + { + if (!_spoolFullWarned) + _logger.LogError("The spool is full ({Max} writes); new writes are dropped until the database is back", _spoolMax); + _spoolFullWarned = true; + return; + } + try + { + File.AppendAllText(_spoolPath, JsonSerializer.Serialize(op) + "\n"); + _spooled++; + } + catch (Exception ex) + { + _logger.LogError(ex, "Couldn't write to the spool {Path}; a {Op} write is lost", _spoolPath, op.GetType().Name); + } + } + + private int CountSpool() + { + try + { + return File.ReadLines(_spoolPath).Count(l => l.Length > 0); + } + catch (FileNotFoundException) + { + return 0; + } + } + + // Worth retrying: the database is unreachable, overloaded, or refusing our login (the password may + // be fixed). Anything else (bad data, a missing table) would fail again forever. + private static bool Transient(Exception ex) => ex switch + { + OperationCanceledException => true, + MySqlException m => m.IsTransient || m.ErrorCode is MySqlErrorCode.UnableToConnectToHost + or MySqlErrorCode.AccessDenied or MySqlErrorCode.DatabaseAccessDenied, + TimeoutException or IOException or System.Net.Sockets.SocketException => true, + _ => ex.InnerException != null && Transient(ex.InnerException), + }; + + private void Succeeded() + { + lock (_stop) + _lastOkAt = DateTime.UtcNow; + _spoolFullWarned = false; + if (Volatile.Read(ref _failing)) + _logger.LogInformation("Database is reachable again"); + Volatile.Write(ref _failing, false); + } + + // Logs once per outage, not per write. + private void Failed(Exception ex, string message) + { + _lastError = ex.Message; + _retryAt = DateTime.UtcNow + RetryInterval; + if (!Volatile.Read(ref _failing)) + _logger.LogError("{Message}: {Error}", message, ex.Message); + Volatile.Write(ref _failing, true); + } +} diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..b190334 --- /dev/null +++ b/release.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Build PlaytimeHistory, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh e.g. ./release.sh v0.1.0 +# +# Commit first: the plugin is rebuilt here from the committed source, so the release always matches +# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with +# the same tag replaces that release's attachment. +# +# Asset: PlaytimeHistory-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf PlaytimeHistory-.tar.gz -C /srv/cs2/game/csgo +# +# addons/counterstrikesharp/plugins/PlaytimeHistory/ PlaytimeHistory.dll, .pdb, .deps.json, +# MySqlConnector.dll, MaxMind.Db.dll and the +# Microsoft.Extensions.* abstractions +# +# No configs/: the plugin writes configs/plugins/PlaytimeHistory/PlaytimeHistory.json (its database +# settings) itself when it's missing, so an upgrade never touches them. The location database +# (dbip-city-lite.mmdb) isn't in here either: pre.sh installs it from DB-IP (DBIP_VERSION). +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. +FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" + +# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj +# (ExcludeAssets=runtime); the server already provides it. +PLUGIN_FILES=( + PlaytimeHistory.dll + PlaytimeHistory.pdb + PlaytimeHistory.deps.json + MySqlConnector.dll + MaxMind.Db.dll + Microsoft.Extensions.DependencyInjection.Abstractions.dll + Microsoft.Extensions.Logging.Abstractions.dll +) + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "usage: $0 (e.g. v0.1.0)" >&2 + exit 1 +fi +if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + exit 1 +fi +for tool in podman jq curl git; do + command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; } +done + +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2 + exit 1 +fi + +# The tag should name the version the plugin reports in `css_plugins list`. +version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' PlaytimeHistory.cs)" +if [[ "${TAG#v}" != "$version" ]]; then + echo "Tag $TAG doesn't match ModuleVersion $version in PlaytimeHistory.cs." >&2 + exit 1 +fi + +# --- Build ----------------------------------------------------------------------- +OUT="release-out" +PUBLISH="$OUT/publish" +rm -rf bin obj "$OUT" +podman run --rm -v "$(pwd)":/src:Z -w /src \ + mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH" + +# --- Stage (layout of game/csgo/) and package ---------------------------------------- +STAGE="$OUT/stage" +PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/PlaytimeHistory" +mkdir -p "$PLUGIN_DIR" +for file in "${PLUGIN_FILES[@]}"; do + cp "$PUBLISH/$file" "$PLUGIN_DIR/" +done + +TARBALL="PlaytimeHistory-${TAG}.tar.gz" +tar -czf "$OUT/$TARBALL" -C "$STAGE" addons +rm -rf "$STAGE" "$PUBLISH" + +echo "Packaged:" +tar -tzf "$OUT/$TARBALL" + +# --- Tag --------------------------------------------------------------------------- +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + git tag -a "$TAG" -m "$TAG" +fi +git push origin "refs/tags/$TAG" + +# --- Release ----------------------------------------------------------------------- +API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO" +AUTH=(-H "Authorization: token $FORGEJO_TOKEN") + +release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)" +if [[ -z "$release_json" ]]; then + body="$(git log -1 --format=%B "$TAG")" + release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \ + '{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' | + curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")" + echo "Created release $TAG" +else + echo "Release $TAG already exists, replacing its attachment" +fi +release_id="$(jq -r '.id' <<<"$release_json")" + +existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")" +if [[ -n "$existing_id" ]]; then + curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null +fi +curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \ + "$API/releases/$release_id/assets?name=$TARBALL" >/dev/null +echo "Uploaded $TARBALL" + +echo +echo "Download URL:" +echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"