using System.Collections.Concurrent; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; using Microsoft.Extensions.Logging; using Outnumbered.Data; namespace Outnumbered; public sealed partial class OutnumberedPlugin { private IPlayerRepository _repo = null!; private readonly ConcurrentDictionary _players = new(); private readonly ConcurrentDictionary _slotToSteam = new(); private CounterStrikeSharp.API.Modules.Timers.Timer? _flushTimer; private void Initialize_Database(bool hotReload) { string serverId = ServerId(); // Backend by Config.Database.Provider: Postgres for production (shared remote DB), SQLite for dev. Both // implement IPlayerRepository + the per-server match scoping; the rest of the plugin is provider-agnostic. string provider = (Config.Database.Provider ?? "sqlite").Trim().ToLowerInvariant(); if (provider is "postgres" or "postgresql" or "pg") { _repo = new NpgsqlRepository(Config.Database.PostgresConnectionString, serverId); Logger.LogInformation("Outnumbered DB: PostgreSQL (match scope: {Srv})", serverId); } #if WITH_SQLITE else { string dbPath = Path.Combine(Server.GameDirectory, "csgo", Config.Database.SqliteFile); Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); _repo = new SqliteRepository(dbPath, serverId); Logger.LogInformation("Outnumbered DB: SQLite {Path} (match scope: {Srv})", dbPath, serverId); } #else else { // Postgres-only build (WithSqlite=false): SQLite isn't compiled in, so fall back to Postgres regardless. Logger.LogWarning("Outnumbered: Database.Provider '{P}' requested but this is a Postgres-only build — using PostgreSQL.", provider); _repo = new NpgsqlRepository(Config.Database.PostgresConnectionString, serverId); Logger.LogInformation("Outnumbered DB: PostgreSQL (match scope: {Srv})", serverId); } #endif Task.Run(async () => { try { await _repo.EnsureSchemaAsync(); Logger.LogInformation("Outnumbered DB schema ready"); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered DB init failed"); } }); RegisterListener(OnClientAuthorized); RegisterListener(OnClientDisconnect); // Periodic flush = crash insurance for permanent progression (a few dirty rows; negligible even on remote PG). // FlushIntervalSeconds <= 0 -> pure RAM-first (write only on disconnect/shutdown). Match state is RAM-first regardless. if (Config.FlushIntervalSeconds > 0) _flushTimer = AddTimer(Config.FlushIntervalSeconds, FlushDirty, TimerFlags.REPEAT); if (hotReload) SeedConnectedPlayers(); } // On hot-reload, OnClientAuthorized won't re-fire for already-connected players (cookbook §0). private void SeedConnectedPlayers() { foreach (var p in Humans()) { var sid = p.AuthorizedSteamID?.SteamId64; if (sid is not null) LoadPlayer(p.Slot, sid.Value); } } private void OnClientAuthorized(int slot, SteamID id) => LoadPlayer(slot, id.SteamId64); private void LoadPlayer(int slot, ulong sid) { _slotToSteam[slot] = sid; Task.Run(async () => { LoadedPlayer? loaded; MatchState? match; try { loaded = await _repo.LoadAsync(sid); match = await _repo.LoadMatchAsync(sid); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered load failed for {Sid}", sid); return; } // Entity/cache mutations back on the game thread. Server.NextFrame(() => { var pd = new PlayerData { SteamId = sid }; if (loaded is not null) { pd.Name = loaded.Name; pd.Xp = loaded.Xp; pd.Level = loaded.Level; pd.Prestige = loaded.Prestige; pd.Points = loaded.Points; pd.PrimaryWeapon = loaded.PrimaryWeapon; pd.SecondaryWeapon = loaded.SecondaryWeapon; foreach (var kv in loaded.Upgrades) pd.Upgrades[kv.Key] = kv.Value; } if (match is not null) // an in-progress match (rejoin); a wiped/absent row loads as fresh zeros { pd.Kills = match.Kills; pd.Deaths = match.Deaths; pd.Streak = match.Streak; pd.HeadshotKills = match.HeadshotKills; pd.GgRunStartedAtMs = match.GgRunStartedAtMs; // restored, never re-armed: a rejoin can't restart the GG clock } var name = Utilities.GetPlayerFromSlot(slot)?.PlayerName; if (!string.IsNullOrEmpty(name)) pd.Name = name; _players[sid] = pd; Logger.LogInformation( "Outnumbered loaded {Sid} '{Name}': L{Lvl} P{Pre} xp={Xp} pts={Pts} ({N} upgrades){New}", sid, pd.Name, pd.Level, pd.Prestige, pd.Xp, pd.Points, pd.Upgrades.Count, loaded is null ? " [new]" : ""); }); }); } private void OnClientDisconnect(int slot) { if (!_slotToSteam.TryRemove(slot, out var sid)) return; if (!_players.TryRemove(sid, out var pd)) return; // survival: bank this player's accumulated run-XP into the main table BEFORE we snapshot (progress-based; run // ends on disconnect — no mid-run reconnect-restore). Sets pd.Dirty so the converted XP is captured below. _driver.OnHumanDisconnect(sid, pd); // If this was the LAST human, the match is OVER (no players = not ongoing) -> wipe the per-server match_state so the // next session starts FRESH (no ghost kills/deaths/streak). While other humans remain, the match is still ongoing, // so we persist this leaver's state for a mid-match rejoin-restore. A graceful shutdown/restart saves via // Shutdown_Database (which does NOT route through OnClientDisconnect — _players is still populated there), so a // restart-while-players-connected still restores; only a genuinely-emptied server resets. bool matchOver = _players.IsEmpty; // capture on the game thread; player is leaving (no rollback needed). var perm = pd.Dirty ? pd.ToPersist() : null; var match = (!matchOver && pd.HasMatchActivity) ? pd.ToMatchState() : null; if (perm is null && match is null && !matchOver) return; Task.Run(async () => { try { if (perm is not null) await _repo.SaveAsync(perm); // permanent progression always persists if (match is not null) await _repo.SaveMatchAsync(match); // match still ongoing -> save for a rejoin-restore if (matchOver) await _repo.WipeMatchAsync(); // server empty -> end the match (fresh next session) Logger.LogInformation("Outnumbered saved {Sid} on disconnect{Over}", sid, matchOver ? " (last player left -> match ended, state wiped)" : ""); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered save-on-disconnect failed for {Sid}", sid); } }); } // Main-thread timer. Snapshot dirty rows, clear optimistically, save async, re-mark on failure. private void FlushDirty() { List? dirty = null; foreach (var pd in _players.Values) if (pd.Dirty) (dirty ??= new()).Add(pd); if (dirty is null) return; var snaps = dirty.Select(pd => pd.ToPersist()).ToList(); foreach (var pd in dirty) pd.Dirty = false; // optimistic; a mutation during the save re-sets it Task.Run(async () => { try { await _repo.SaveManyAsync(snaps); Logger.LogInformation("Outnumbered flushed {N} player(s)", snaps.Count); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered flush failed; re-marking dirty"); Server.NextFrame(() => { foreach (var pd in dirty) pd.Dirty = true; }); } }); } private void Shutdown_Database() { _flushTimer?.Kill(); var snaps = _players.Values.Where(p => p.Dirty).Select(p => p.ToPersist()).ToList(); var matchSnaps = _players.Values.Where(p => p.HasMatchActivity).Select(p => p.ToMatchState()).ToList(); _players.Clear(); _slotToSteam.Clear(); if (snaps.Count == 0 && matchSnaps.Count == 0) return; // Bounded blocking flush — fire-and-forget here races the changelevel/unload (cookbook §1). try { if (snaps.Count > 0) _repo.SaveManyAsync(snaps).GetAwaiter().GetResult(); if (matchSnaps.Count > 0) _repo.SaveManyMatchAsync(matchSnaps).GetAwaiter().GetResult(); Logger.LogInformation("Outnumbered flushed {N} player(s) + {M} match row(s) on unload", snaps.Count, matchSnaps.Count); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered unload flush failed"); } } // Per-instance tag that scopes the ephemeral match_state so MANY servers (even sharing one DB / one install // dir) don't clobber each other's round. Permanent progression / the leaderboard stays GLOBAL — only the round // table is per-server. Read from the process command line (machine-independent, available immediately): // -outnumbered_server (recommended; any parse-able value, like -outnumbered_mode) -> // the game port (-port/+hostport; unique per instance on ONE box, not across boxes) -> "default". internal string ServerId() { try { var args = CommandLineArgs(); string? tag = ArgValue(args, "outnumbered_server"); if (!string.IsNullOrWhiteSpace(tag)) return Sanitize(tag); string? port = ArgValue(args, "port") ?? ArgValue(args, "hostport"); if (!string.IsNullOrWhiteSpace(port)) return Sanitize("port-" + port); } catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered: server-tag resolution failed; using 'default'"); } return "default"; } // The server's REAL launch command line. Environment.GetCommandLineArgs() is unreliable inside the CS2/CSSharp // embedded .NET host (it often returns only the module path, NOT the cs2 process argv), so launch flags like // -outnumbered_mode / -outnumbered_server are invisible through it. On Linux /proc/self/cmdline is the // authoritative NUL-separated argv of the actual cs2 process and always carries the full launch line. Falls back // to the managed args if /proc is unavailable (non-Linux / sandboxed). private static string[] CommandLineArgs() { try { const string p = "/proc/self/cmdline"; if (File.Exists(p)) { var parts = File.ReadAllText(p).Split('\0', StringSplitOptions.RemoveEmptyEntries); if (parts.Length > 1) return parts; } } catch { /* fall through to the managed args */ } return Environment.GetCommandLineArgs(); } // First value for `-name` / `+name` on the command line (both `-name value` and `-name=value` forms), or null. // A value-less space form (the next token is itself a flag, e.g. `-outnumbered_server -port 27015`) returns null so // callers fall through to their next source instead of swallowing the following flag as the value. private static string? ArgValue(string[] args, string name) { string dash = "-" + name, plus = "+" + name; for (int i = 0; i < args.Length; i++) { string a = args[i]; if (a.StartsWith(dash + "=", StringComparison.OrdinalIgnoreCase)) return a[(dash.Length + 1)..]; if (a.StartsWith(plus + "=", StringComparison.OrdinalIgnoreCase)) return a[(plus.Length + 1)..]; if ((a.Equals(dash, StringComparison.OrdinalIgnoreCase) || a.Equals(plus, StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Length) { string v = args[i + 1]; return v.StartsWith('-') || v.StartsWith('+') ? null : v; } } return null; } private static string Sanitize(string s) { string r = s.Trim().Replace(' ', '_'); return r.Length > 64 ? r[..64] : r; } // Clear the match table — called at match boundaries (kill-goal rotation + any map start after the first). The // first map start after plugin load does NOT call this, so a match dumped on shutdown survives to be restored on rejoin. private void WipeMatch() => Task.Run(async () => { try { await _repo.WipeMatchAsync(); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered match-state wipe failed"); } }); // ---- records (site leaderboards): improve-only, fire-and-forget. Deliberately outside the Dirty/flush machinery — // the repo upserts are clobber-safe on their own, so nothing here round-trips through PlayerData. ---- // Survival: every participant of a cleared wave. Higher wave wins. internal void RecordBestWaves(List participants, int wave) => Task.Run(async () => { try { await _repo.TryImproveBestWavesAsync(participants, wave); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered best-wave record failed (wave {Wave})", wave); } }); // Gun Game: stop the speedrun clock at the human ladder win; lower time wins. Returns the elapsed ms for the win // banner, or null when there's nothing sane to record (clock never armed, or skewed across a restart-restore). internal long? RecordGgWin(PlayerData apd) { if (apd.GgRunStartedAtMs <= 0) return null; long ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - apd.GgRunStartedAtMs; if (ms <= 0) return null; ulong sid = apd.SteamId; Task.Run(async () => { try { await _repo.TryImproveGgBestAsync(sid, ms); } catch (Exception ex) { Logger.LogError(ex, "Outnumbered gg-best record failed for {Sid}", sid); } }); return ms; } internal static string FormatRunTime(long ms) => $"{ms / 60000}:{ms / 1000 % 60:D2}.{ms % 1000:D3}"; }