649 lines
26 KiB
C#
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";
|
|
}
|
|
}
|