initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

293
Outnumbered/Persistence.cs Normal file
View file

@ -0,0 +1,293 @@
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<ulong, PlayerData> _players = new();
private readonly ConcurrentDictionary<int, ulong> _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<Listeners.OnClientAuthorized>(OnClientAuthorized);
RegisterListener<Listeners.OnClientDisconnect>(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<PlayerData>? 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 <tag> (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<ulong> 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}";
}