PlaytimeHistory 0.1.0: sessions and team/alive segments in MySQL, DB-IP locations
This commit is contained in:
commit
aad86dd1c7
11 changed files with 1989 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bin/
|
||||
obj/
|
||||
release-out/
|
||||
81
Config.cs
Normal file
81
Config.cs
Normal file
|
|
@ -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";
|
||||
}
|
||||
357
Database.cs
Normal file
357
Database.cs
Normal file
|
|
@ -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<MySqlSslMode>(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<MySqlConnection> 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<List<string>> 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<string>();
|
||||
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<Summary?> 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<List<RecentSession>> 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<RecentSession>();
|
||||
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<int> 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];
|
||||
}
|
||||
53
Geo.cs
Normal file
53
Geo.cs
Normal file
|
|
@ -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<Dictionary<string, object>>(ip);
|
||||
if (data == null)
|
||||
return null;
|
||||
|
||||
string? iso = null, country = null, city = null;
|
||||
if (data.TryGetValue("country", out var c) && c is Dictionary<string, object> 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<string, object> cityData)
|
||||
city = EnglishName(cityData);
|
||||
return iso == null && city == null ? null : new Location(iso, country, city);
|
||||
}
|
||||
|
||||
private static string? EnglishName(Dictionary<string, object> data) =>
|
||||
data.TryGetValue("names", out var n) && n is Dictionary<string, object> names &&
|
||||
names.TryGetValue("en", out var en) ? en as string : null;
|
||||
|
||||
public void Dispose() => _reader.Dispose();
|
||||
}
|
||||
138
Migrations/001_CreateTables.sql
Normal file
138
Migrations/001_CreateTables.sql
Normal file
|
|
@ -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;
|
||||
71
Ops.cs
Normal file
71
Ops.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
655
PlaytimeHistory.cs
Normal file
655
PlaytimeHistory.cs
Normal file
|
|
@ -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<PlaytimeHistoryConfig>
|
||||
{
|
||||
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<int, Tracked> _tracked = [];
|
||||
// Caller SteamID64 -> when they may use a lookup command again (UTC).
|
||||
private readonly Dictionary<ulong, DateTime> _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<Listeners.OnMapStart>(OnMapStart);
|
||||
RegisterListener<Listeners.OnMapEnd>(OnMapEnd);
|
||||
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
|
||||
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
|
||||
RegisterEventHandler<EventPlayerTeam>(OnPlayerTeam);
|
||||
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn);
|
||||
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath);
|
||||
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect, HookMode.Pre);
|
||||
RegisterEventHandler<EventRoundEnd>(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<int>();
|
||||
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)} <name|#userid|steamid64>");
|
||||
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<string> 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<CCSPlayerController> 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<string> 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<string> DescribeSessions(string name, List<Database.RecentSession> sessions)
|
||||
{
|
||||
if (sessions.Count == 0)
|
||||
return [$"No sessions recorded for {name}."];
|
||||
var lines = new List<string> { $"{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<string> 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";
|
||||
}
|
||||
}
|
||||
25
PlaytimeHistory.csproj
Normal file
25
PlaytimeHistory.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>PlaytimeHistory</AssemblyName>
|
||||
<RootNamespace>PlaytimeHistory</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
|
||||
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
|
||||
<PrivateAssets>none</PrivateAssets>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- The same MySQL driver and version as CS2-SimpleAdmin and GroupVerify; ships with the plugin -->
|
||||
<PackageReference Include="MySqlConnector" Version="2.6.0" />
|
||||
<!-- Reads DB-IP's location database (MaxMind DB format); the same version jRandomSkills ships -->
|
||||
<PackageReference Include="MaxMind.Db" Version="5.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Embedded, so the tables are created without any extra files on the server -->
|
||||
<EmbeddedResource Include="Migrations/*.sql" LogicalName="PlaytimeHistory.Migrations.%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
209
README.md
Normal file
209
README.md
Normal file
|
|
@ -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 '<strong password>';
|
||||
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": "<the 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 <a href="https://db-ip.com">DB-IP</a>`.
|
||||
|
||||
`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 <tag>` (with `FORGEJO_TOKEN`) rebuilds from the committed source and publishes
|
||||
`PlaytimeHistory-<tag>.tar.gz`, laid out like `game/csgo/`. The tag must be `v<ModuleVersion>`. 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/`.
|
||||
275
Writer.cs
Normal file
275
Writer.cs
Normal file
|
|
@ -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<Op> _queue = Channel.CreateUnbounded<Op>(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<Op>(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);
|
||||
}
|
||||
}
|
||||
122
release.sh
Executable file
122
release.sh
Executable file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build PlaytimeHistory, package it as one tarball and publish it as a Forgejo release.
|
||||
#
|
||||
# ./release.sh <tag> 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-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
|
||||
# tar -xzf PlaytimeHistory-<tag>.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 <tag> (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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue