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). Kills, bomb plants and defuses are logged as they happen. // Warmup counts as playtime, but not toward match stats or events. Only country and city are kept // from a player's IP. // // Threads: every field below is only touched on the game thread. Database writes are queued to the // Writer; command lookups run in Task.Run and hand their answer back with Server.NextFrame. public class PlaytimeHistory : BasePlugin, IPluginConfig { public override string ModuleName => "PlaytimeHistory"; public override string ModuleVersion => "0.2.1"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Records playtime per session, team and alive/dead state"; 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 = ""; // The hostname last written to pt_servers. The server.cfg that sets it runs after map start, so // the heartbeat checks again and sends it when it's changed. private string? _sentHostname; // False between a map's end and the next one's start, when nothing is opened. private bool _mapRunning; // 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; // The map's running stats as last read, and this session's total so far (null until they've // been read once). Read every second, so a close that can't read them still has them. public MatchStats? LastStats; public MatchStats? Stats; public string? SegmentKey; public DateTime SegmentStart; public int Team; 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(); // Null while the server is still starting, before the first map: OnMapStart sets it. _map = Server.MapName ?? ""; _mapRunning = _map.Length > 0; RegisterListener(OnMapStart); RegisterListener(OnMapEnd); RegisterListener(OnClientDisconnect); RegisterEventHandler(OnPlayerConnectFull); RegisterEventHandler(OnPlayerTeam); RegisterEventHandler(OnPlayerSpawn); RegisterEventHandler(OnPlayerDeath); RegisterEventHandler(OnPlayerDisconnect, HookMode.Pre); RegisterEventHandler(OnRoundEnd); RegisterEventHandler(OnBombPlanted); RegisterEventHandler(OnBombDefused); 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); if (_mapRunning) ServerSeenNow(); AddTimer(1.0f, Reconcile, TimerFlags.REPEAT); AddTimer(Config.HeartbeatSeconds, () => { _writer.Enqueue(new Heartbeat(Config.ServerId, Now())); if (_mapRunning) ServerSeenNow(onlyIfChanged: true); }, TimerFlags.REPEAT); AddTimer(Config.PingSampleIntervalSeconds, SamplePing, TimerFlags.REPEAT); if (Config.OnlineSnapshotIntervalSeconds > 0) AddTimer(Config.OnlineSnapshotIntervalSeconds, TakeSnapshot, TimerFlags.REPEAT); } 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 InWarmup() => Utilities.FindAllEntitiesByDesignerName("cs_gamerules").FirstOrDefault()?.GameRules?.WarmupPeriod ?? false; private static bool Trackable(CCSPlayerController? player) => player is { IsValid: true, IsBot: false, IsHLTV: false } && player.Connected == PlayerConnectedState.Connected && player.SteamID != 0; private void ServerSeenNow(bool onlyIfChanged = false) { var hostname = ConVar.Find("hostname")?.StringValue ?? ""; if (onlyIfChanged && hostname == _sentHostname) return; _sentHostname = hostname; _writer?.Enqueue(new ServerSeen(Config.ServerId, Config.PublicAddress, hostname, Now())); } // --- 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 warmup = InWarmup(); 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); SampleStats(tracked, player!, warmup); } } foreach (var slot in _tracked.Keys.Where(s => !seen.Contains(s)).ToList()) CloseSession(slot, _tracked[slot], EndKind.Disconnect, now, null, null); } 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() }; SampleStats(tracked, player, InWarmup()); _tracked[slot] = tracked; _writer!.Enqueue(new SessionOpen(tracked.SessionKey, tracked.SteamId, Config.ServerId, player.PlayerName, now, _map, _tracked.Count, Server.MaxPlayers, location?.CountryIso, location?.CountryName, location?.City, ModuleVersion)); 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)); if (player != null) SampleStats(tracked, player, InWarmup()); var stats = tracked.Stats; var ping = tracked.PingSamples > 0 ? new PingStats((int)(tracked.PingSum / tracked.PingSamples), tracked.PingMin, tracked.PingMax, tracked.PingSamples) : null; 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) { try { var stats = player.ActionTrackingServices?.MatchStats; if (stats == null) return null; return new MatchStats(stats.Kills, stats.Deaths, stats.Assists, stats.HeadShotKills, stats.Damage, player.MVPs, player.Score); } catch (Exception) { return null; } } // Adds what the map's running stats gained since the last read to the session's total. The first // read is only the starting point: what's there already happened before this session. Kills and // score can go down (a teamkill), but deaths and damage only go down when the game resets the // stats (the match starting after warmup, say); then all of the new values count. During warmup // the reads only move the starting point along: warmup counts as playtime, not toward stats. private static void SampleStats(Tracked tracked, CCSPlayerController player, bool warmup) { var now = ReadStats(player); if (now == null) return; var last = tracked.LastStats; tracked.LastStats = now; if (last == null || warmup) { tracked.Stats ??= new MatchStats(0, 0, 0, 0, 0, 0, 0); return; } if (now.Deaths < last.Deaths || now.Damage < last.Damage) last = new MatchStats(0, 0, 0, 0, 0, 0, 0); var s = tracked.Stats!; tracked.Stats = new MatchStats(s.Kills + now.Kills - last.Kills, s.Deaths + now.Deaths - last.Deaths, s.Assists + now.Assists - last.Assists, s.Headshots + now.Headshots - last.Headshots, s.Damage + now.Damage - last.Damage, s.Mvp + now.Mvp - last.Mvp, s.Score + now.Score - last.Score); } private Geo.Location? LookupLocation(string? address) { 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); RecordKill(@event); return HookResult.Continue; } // Logs a kill by a tracked player. Not suicides, team kills or anything in warmup. private void RecordKill(EventPlayerDeath @event) { var killer = @event.Attacker; var victim = @event.Userid; if (_writer == null || !_mapRunning || !Trackable(killer) || victim is not { IsValid: true } || victim.Slot == killer!.Slot || victim.Team == killer.Team || InWarmup()) return; var bot = victim.IsBot; _writer.Enqueue(new EventRecord(NewKey(), Config.ServerId, SessionKeyOf(killer), killer.SteamID, EventKind.Kill, Now(), _map, (int)killer.Team, Weapon: @event.Weapon, Headshot: @event.Headshot, Noscope: @event.Noscope, Thrusmoke: @event.Thrusmoke, Wallbang: @event.Penetrated > 0, Blind: @event.Attackerblind, VictimSteamId: bot || victim.SteamID == 0 ? null : victim.SteamID, VictimBot: bot)); } private HookResult OnBombPlanted(EventBombPlanted @event, GameEventInfo info) { RecordBomb(@event.Userid, EventKind.BombPlanted, @event.Site); return HookResult.Continue; } private HookResult OnBombDefused(EventBombDefused @event, GameEventInfo info) { RecordBomb(@event.Userid, EventKind.BombDefused, @event.Site); return HookResult.Continue; } private void RecordBomb(CCSPlayerController? player, EventKind kind, int site) { if (_writer == null || !_mapRunning || !Trackable(player) || InWarmup()) return; _writer.Enqueue(new EventRecord(NewKey(), Config.ServerId, SessionKeyOf(player!), player!.SteamID, kind, Now(), _map, (int)player.Team, Site: site)); } // The session open for this player, if it's theirs. private string? SessionKeyOf(CCSPlayerController player) => _tracked.TryGetValue(player.Slot, out var tracked) && tracked.SteamId == player.SteamID ? tracked.SessionKey : null; // The controller and pawn have caught up with the event by the next frame. private void UpdateNextFrame(CCSPlayerController? player) { if (player is not { IsValid: true }) return; var slot = player.Slot; Server.NextFrame(() => UpdateSlot(slot)); } private HookResult OnRoundEnd(EventRoundEnd @event, GameEventInfo info) { if (InWarmup()) return HookResult.Continue; foreach (var tracked in _tracked.Values) if (Teams.Playing(tracked.Team)) tracked.Rounds++; 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"; } }