PlaytimeHistory/PlaytimeHistory.cs

649 lines
26 KiB
C#

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.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 = "";
// 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();
// Null while the server is still starting, before the first map: OnMapStart sets it.
_map = Server.MapName ?? "";
_mapRunning = _map.Length > 0;
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);
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";
}
}