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

View file

@ -0,0 +1,187 @@
using System.Data.Common;
using Dapper;
namespace Outnumbered.Data;
// Shared Dapper implementation for both backends. SQLite (dev) and Postgres (prod) differ ONLY in how a connection is
// opened and in the schema DDL (INTEGER vs BIGINT, the SQLite-only drop-migration); every query and the row records are
// identical and live here once, so a new persisted column is a one-place change. Subclasses supply OpenConnectionAsync +
// EnsureSchemaAsync. Steam IDs are stored signed via unchecked((long)id), round-tripping identically on both. All methods
// run OFF the game thread. Permanent progression (players/upgrades) is GLOBAL; match_state is PER-SERVER (composite key)
// so many instances share one DB without clobbering each other's round.
public abstract class DapperPlayerRepository(string serverId) : IPlayerRepository
{
protected readonly string ServerId = string.IsNullOrEmpty(serverId) ? "default" : serverId; // scopes the per-round match_state
protected abstract Task<DbConnection> OpenConnectionAsync();
public abstract Task EnsureSchemaAsync();
public async Task<LoadedPlayer?> LoadAsync(ulong steamId)
{
await using var c = await OpenConnectionAsync();
long sid = unchecked((long)steamId);
var row = await c.QuerySingleOrDefaultAsync<PlayerRow>(
"""
SELECT name, xp, level, prestige, points,
primary_weapon AS PrimaryWeapon, secondary_weapon AS SecondaryWeapon
FROM players WHERE steamid = @sid
""", new { sid });
if (row is null) return null;
var ups = await c.QueryAsync<UpgradeRow>(
"SELECT stat_key, level FROM upgrades WHERE steamid = @sid", new { sid });
return new LoadedPlayer(row.Name, row.Xp, (int)row.Level, (int)row.Prestige, (int)row.Points,
ups.ToDictionary(u => u.Stat_Key, u => (int)u.Level), row.PrimaryWeapon, row.SecondaryWeapon);
}
public async Task<IReadOnlyList<TopPlayer>> GetTopAsync(int count)
{
await using var c = await OpenConnectionAsync();
var rows = await c.QueryAsync<TopRow>(
"SELECT name, level, prestige, xp FROM players ORDER BY prestige DESC, level DESC, xp DESC LIMIT @n",
new { n = count });
return rows.Select(r => new TopPlayer(r.Name, (int)r.Level, (int)r.Prestige, r.Xp)).ToList();
}
// Records: improve-only upserts. The bare INSERT relies on the players-table defaults for a first-ever row (the
// name backfills on the player's next regular save); the CASE keeps the better value on conflict. Existing-row
// references MUST be table-qualified: in DO UPDATE both the target row and `excluded` are in scope, and Postgres
// rejects the unqualified form as ambiguous (42702). SQLite accepts the qualified form too, so the SQL stays shared.
public async Task TryImproveBestWavesAsync(IReadOnlyList<ulong> steamIds, int wave)
{
if (steamIds.Count == 0) return;
await using var c = await OpenConnectionAsync();
await using var tx = await c.BeginTransactionAsync();
foreach (var id in steamIds.Order()) // ascending steamid = the global row-lock order (see SaveManyAsync)
await c.ExecuteAsync(
"""
INSERT INTO players(steamid, best_wave) VALUES(@sid, @w)
ON CONFLICT(steamid) DO UPDATE SET best_wave =
CASE WHEN players.best_wave IS NULL OR players.best_wave < excluded.best_wave
THEN excluded.best_wave ELSE players.best_wave END;
""", new { sid = unchecked((long)id), w = wave }, tx);
await tx.CommitAsync();
}
public async Task TryImproveGgBestAsync(ulong steamId, long elapsedMs)
{
await using var c = await OpenConnectionAsync();
await c.ExecuteAsync(
"""
INSERT INTO players(steamid, gg_best_ms) VALUES(@sid, @ms)
ON CONFLICT(steamid) DO UPDATE SET gg_best_ms =
CASE WHEN players.gg_best_ms IS NULL OR players.gg_best_ms > excluded.gg_best_ms
THEN excluded.gg_best_ms ELSE players.gg_best_ms END;
""", new { sid = unchecked((long)steamId), ms = elapsedMs });
}
public async Task<IReadOnlyList<TopWave>> GetTopWavesAsync(int count)
{
await using var c = await OpenConnectionAsync();
var rows = await c.QueryAsync<WaveRow>(
"SELECT name, best_wave AS BestWave FROM players WHERE best_wave IS NOT NULL ORDER BY best_wave DESC, xp DESC LIMIT @n",
new { n = count });
return rows.Select(r => new TopWave(r.Name, (int)r.BestWave)).ToList();
}
public async Task<IReadOnlyList<TopGgTime>> GetTopGgAsync(int count)
{
await using var c = await OpenConnectionAsync();
var rows = await c.QueryAsync<GgTimeRow>(
"SELECT name, gg_best_ms AS BestMs FROM players WHERE gg_best_ms IS NOT NULL ORDER BY gg_best_ms ASC, xp DESC LIMIT @n",
new { n = count });
return rows.Select(r => new TopGgTime(r.Name, r.BestMs)).ToList();
}
public Task SaveAsync(PersistPlayer player) => SaveManyAsync(new[] { player });
public async Task SaveManyAsync(IReadOnlyList<PersistPlayer> players)
{
if (players.Count == 0) return;
await using var c = await OpenConnectionAsync();
await using var tx = await c.BeginTransactionAsync();
// Ascending steamid in EVERY multi-row transaction (here, the match twin, the records improves) = one global
// row-lock order, so a wave-clear improve and the periodic flush can't deadlock each other on Postgres.
foreach (var p in players.OrderBy(x => x.SteamId))
{
long sid = unchecked((long)p.SteamId);
await c.ExecuteAsync(
"""
INSERT INTO players(steamid, name, xp, level, prestige, points, primary_weapon, secondary_weapon, last_seen)
VALUES(@sid, @name, @xp, @level, @prestige, @points, @primary, @secondary, CURRENT_TIMESTAMP)
ON CONFLICT(steamid) DO UPDATE SET
xp = @xp, name = @name, level = @level, prestige = @prestige, points = @points,
primary_weapon = @primary, secondary_weapon = @secondary, last_seen = CURRENT_TIMESTAMP;
""",
new
{
sid,
name = p.Name,
xp = p.Xp,
level = p.Level,
prestige = p.Prestige,
points = p.Points,
primary = p.PrimaryWeapon,
secondary = p.SecondaryWeapon
}, tx);
// Full-replace the upgrade set so a prestige reset's removals actually clear (a plain UPSERT leaves stale rows
// that reload as if the reset never happened).
await c.ExecuteAsync("DELETE FROM upgrades WHERE steamid = @sid;", new { sid }, tx);
foreach (var kv in p.Upgrades)
await c.ExecuteAsync("INSERT INTO upgrades(steamid, stat_key, level) VALUES(@sid, @k, @l);",
new { sid, k = kv.Key, l = kv.Value }, tx);
}
await tx.CommitAsync();
}
public async Task<MatchState?> LoadMatchAsync(ulong steamId)
{
await using var c = await OpenConnectionAsync();
long sid = unchecked((long)steamId);
var row = await c.QuerySingleOrDefaultAsync<MatchRow>(
"SELECT kills, deaths, streak, headshot_kills AS HeadshotKills, gg_run_started_at AS GgRunStartedAtMs FROM match_state WHERE server_id = @srv AND steamid = @sid",
new { srv = ServerId, sid });
return row is null ? null
: new MatchState(steamId, (int)row.Kills, (int)row.Deaths, (int)row.Streak, (int)row.HeadshotKills, row.GgRunStartedAtMs ?? 0);
}
public Task SaveMatchAsync(MatchState state) => SaveManyMatchAsync(new[] { state });
public async Task SaveManyMatchAsync(IReadOnlyList<MatchState> states)
{
if (states.Count == 0) return;
await using var c = await OpenConnectionAsync();
await using var tx = await c.BeginTransactionAsync();
foreach (var s in states.OrderBy(x => x.SteamId)) // global row-lock order (see SaveManyAsync)
{
long sid = unchecked((long)s.SteamId);
await c.ExecuteAsync(
"""
INSERT INTO match_state(server_id, steamid, kills, deaths, streak, headshot_kills, gg_run_started_at)
VALUES(@srv, @sid, @kills, @deaths, @streak, @hs, @ggStart)
ON CONFLICT(server_id, steamid) DO UPDATE SET
kills = @kills, deaths = @deaths, streak = @streak, headshot_kills = @hs, gg_run_started_at = @ggStart;
""",
new { srv = ServerId, sid, kills = s.Kills, deaths = s.Deaths, streak = s.Streak, hs = s.HeadshotKills, ggStart = s.GgRunStartedAtMs }, tx);
}
await tx.CommitAsync();
}
public async Task WipeMatchAsync()
{
await using var c = await OpenConnectionAsync();
await c.ExecuteAsync("DELETE FROM match_state WHERE server_id = @srv;", new { srv = ServerId });
}
// Dapper maps columns by name (case-insensitive). Integer columns -> Int64, so these are `long` (cast to int at the
// call site). The aliases + the Stat_Key param name map the snake_case columns (Postgres folds unquoted to lowercase).
protected sealed record PlayerRow(string Name, long Xp, long Level, long Prestige, long Points,
string? PrimaryWeapon, string? SecondaryWeapon);
protected sealed record UpgradeRow(string Stat_Key, long Level);
protected sealed record TopRow(string Name, long Level, long Prestige, long Xp);
protected sealed record WaveRow(string Name, long BestWave);
protected sealed record GgTimeRow(string Name, long BestMs);
protected sealed record MatchRow(long Kills, long Deaths, long Streak, long HeadshotKills, long? GgRunStartedAtMs);
}

View file

@ -0,0 +1,25 @@
namespace Outnumbered.Data;
// Hides the SQL dialect so SQLite (dev) -> PostgreSQL (prod) is a provider swap,
// not a logic change (spec §10). All methods run OFF the game thread.
public interface IPlayerRepository
{
Task EnsureSchemaAsync();
Task<LoadedPlayer?> LoadAsync(ulong steamId);
Task SaveAsync(PersistPlayer player);
Task SaveManyAsync(IReadOnlyList<PersistPlayer> players);
Task<IReadOnlyList<TopPlayer>> GetTopAsync(int count);
// Records (site leaderboards): improve-only writes kept OUT of the SaveMany upsert's column list, so a normal
// save can never clobber them and they need no LoadedPlayer/PlayerData round-trip (write-only from the plugin).
Task TryImproveBestWavesAsync(IReadOnlyList<ulong> steamIds, int wave); // higher wave wins
Task TryImproveGgBestAsync(ulong steamId, long elapsedMs); // lower time wins
Task<IReadOnlyList<TopWave>> GetTopWavesAsync(int count);
Task<IReadOnlyList<TopGgTime>> GetTopGgAsync(int count);
// Per-match stats (RAM-first; persisted only on disconnect/shutdown, wiped on round end).
Task<MatchState?> LoadMatchAsync(ulong steamId);
Task SaveMatchAsync(MatchState state);
Task SaveManyMatchAsync(IReadOnlyList<MatchState> states);
Task WipeMatchAsync();
}

View file

@ -0,0 +1,78 @@
namespace Outnumbered.Data;
// DTO for the !top leaderboard.
public sealed record TopPlayer(string Name, int Level, int Prestige, long Xp);
// DTOs for the records leaderboards (survival best wave / fastest Gun Game ladder). Names only — no SteamIDs on the wire.
public sealed record TopWave(string Name, int BestWave);
public sealed record TopGgTime(string Name, long BestMs);
// DTO returned by a load.
public sealed record LoadedPlayer(
string Name, long Xp, int Level, int Prestige, int Points, Dictionary<string, int> Upgrades,
string? PrimaryWeapon, string? SecondaryWeapon);
// DTO handed to a save (absolute values; last-write-wins).
public sealed record PersistPlayer(
ulong SteamId, string Name, long Xp, int Level, int Prestige, int Points,
IReadOnlyDictionary<string, int> Upgrades, string? PrimaryWeapon, string? SecondaryWeapon);
// Per-match stats (RAM-first: written only on disconnect/shutdown, wiped on round end). Restored on rejoin mid-match.
public sealed record MatchState(ulong SteamId, int Kills, int Deaths, int Streak, int HeadshotKills, long GgRunStartedAtMs = 0);
// In-memory cache row. Mutated ONLY on the main game thread.
public sealed class PlayerData
{
public required ulong SteamId { get; init; }
public string Name { get; set; } = "";
public long Xp { get; set; } // progress toward the next level (within the current prestige)
public int Level { get; set; } = 1;
public int Prestige { get; set; }
public int Points { get; set; } = 1; // start with one spendable point (spec §2)
public Dictionary<string, int> Upgrades { get; } = new();
// Chosen loadout (null = use the config default). Persisted. Free selection, validated against the allowed lists.
public string? PrimaryWeapon { get; set; }
public string? SecondaryWeapon { get; set; }
public bool Dirty { get; set; }
// Runtime-only: sub-1 XP remainder carried between per-hit grants so fractional damage-XP isn't lost to rounding.
public double XpCarry { get; set; }
// Runtime-only: combat tracking for the handicap (and killstreak abilities).
public int Kills { get; set; }
public int Deaths { get; set; }
public int Streak { get; set; } // current killstreak; resets on death
public int HeadshotKills { get; set; } // headshot kills this match — feeds the handicap's HS-rate factor
// Runtime-only (Gun Game mode): current weapon-ladder rung + kills banked toward clearing it. Persist across
// deaths within a match, reset on map start. NOT round-tripped in match_state — a mid-match reconnect
// restarts the climb at rung 0.
public int GgRung { get; set; }
public int GgRungKills { get; set; }
// Gun Game speedrun clock: wall-clock unix ms of the run's first damage dealt-or-taken (0 = not armed; reset on map
// start). Wall clock, not Server.CurrentTime, because it round-trips through match_state and must stay comparable
// across a changelevel/restart (the game clock restarts at ~0 there). Unlike GgRung, this DOES round-trip — a
// mid-match reconnect restarts the climb but never restarts the clock (the once-per-match anti-abuse rule).
public long GgRunStartedAtMs { get; set; }
// Runtime-only: killstreak ability state, indexed by AbilityRegistry index (0..N-1).
// AbilityReadyAt = Server.CurrentTime at which the ability comes off cooldown (survives death).
// AbilityActiveUntil = Server.CurrentTime until which the ability's effect is live (cleared on death).
// Sized to AbilitySlots (headroom over the current 5) so adding an AbilityRegistry row can't IndexOutOfRange; a
// startup guard (Initialize_Abilities) errors loudly if the registry ever exceeds this.
public const int AbilitySlots = 8;
public double[] AbilityReadyAt { get; } = new double[AbilitySlots];
public double[] AbilityActiveUntil { get; } = new double[AbilitySlots];
public PersistPlayer ToPersist() =>
new(SteamId, Name, Xp, Level, Prestige, Points, new Dictionary<string, int>(Upgrades),
PrimaryWeapon, SecondaryWeapon);
public MatchState ToMatchState() => new(SteamId, Kills, Deaths, Streak, HeadshotKills, GgRunStartedAtMs);
// GgRunStartedAtMs counts as activity: an armed-but-zero-kill speedrun clock must still reach match_state,
// or a disconnect before the first kill silently discards it (this predicate gates every match-state save).
public bool HasMatchActivity => Kills > 0 || Deaths > 0 || Streak > 0 || HeadshotKills > 0 || GgRunStartedAtMs > 0;
}

View file

@ -0,0 +1,58 @@
using System.Data.Common;
using Dapper;
using Npgsql;
namespace Outnumbered.Data;
// PostgreSQL backend — the PRODUCTION store (Provider="postgres"). Shares all queries with DapperPlayerRepository;
// supplies only the connection (Npgsql pooling per connection string; MVCC, no WAL/busy_timeout pragmas) and the schema
// DDL (BIGINT columns -> Dapper Int64 -> the same `long` row records). No drop-migration; later columns reach
// existing prod tables via the ADD COLUMN IF NOT EXISTS block (CREATE TABLE IF NOT EXISTS alone never alters them).
public sealed class NpgsqlRepository(string connectionString, string serverId) : DapperPlayerRepository(serverId)
{
private readonly string _connectionString = connectionString;
protected override async Task<DbConnection> OpenConnectionAsync()
{
var c = new NpgsqlConnection(_connectionString);
await c.OpenAsync();
return c;
}
public override async Task EnsureSchemaAsync()
{
await using var c = await OpenConnectionAsync();
await c.ExecuteAsync(
"""
CREATE TABLE IF NOT EXISTS players(
steamid BIGINT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
xp BIGINT NOT NULL DEFAULT 0,
level BIGINT NOT NULL DEFAULT 1,
prestige BIGINT NOT NULL DEFAULT 0,
points BIGINT NOT NULL DEFAULT 1,
primary_weapon TEXT,
secondary_weapon TEXT,
best_wave BIGINT,
gg_best_ms BIGINT,
last_seen TIMESTAMPTZ);
CREATE TABLE IF NOT EXISTS upgrades(
steamid BIGINT NOT NULL,
stat_key TEXT NOT NULL,
level BIGINT NOT NULL,
PRIMARY KEY(steamid, stat_key));
CREATE TABLE IF NOT EXISTS match_state(
server_id TEXT NOT NULL DEFAULT 'default',
steamid BIGINT NOT NULL,
kills BIGINT NOT NULL DEFAULT 0,
deaths BIGINT NOT NULL DEFAULT 0,
streak BIGINT NOT NULL DEFAULT 0,
headshot_kills BIGINT NOT NULL DEFAULT 0,
gg_run_started_at BIGINT,
PRIMARY KEY(server_id, steamid));
ALTER TABLE players ADD COLUMN IF NOT EXISTS best_wave BIGINT;
ALTER TABLE players ADD COLUMN IF NOT EXISTS gg_best_ms BIGINT;
ALTER TABLE match_state ADD COLUMN IF NOT EXISTS gg_run_started_at BIGINT;
""");
}
}

View file

@ -0,0 +1,82 @@
#if WITH_SQLITE
using System.Data.Common;
using Dapper;
using Microsoft.Data.Sqlite;
namespace Outnumbered.Data;
// SQLite backend — the DEV store (Provider="sqlite"). Shares all queries with DapperPlayerRepository; supplies only the
// connection (WAL + busy_timeout so periodic flush / disconnect saves don't trip over each other) and the schema DDL.
public sealed class SqliteRepository : DapperPlayerRepository
{
private readonly string _connectionString;
public SqliteRepository(string dbFilePath, string serverId) : base(serverId) =>
_connectionString = new SqliteConnectionStringBuilder { DataSource = dbFilePath }.ToString();
protected override async Task<DbConnection> OpenConnectionAsync()
{
var c = new SqliteConnection(_connectionString);
await c.OpenAsync();
await c.ExecuteAsync("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
return c;
}
public override async Task EnsureSchemaAsync()
{
await using var c = await OpenConnectionAsync();
// Permanent progression — GLOBAL across all servers (shared leaderboard).
await c.ExecuteAsync(
"""
CREATE TABLE IF NOT EXISTS players(
steamid INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
prestige INTEGER NOT NULL DEFAULT 0,
points INTEGER NOT NULL DEFAULT 1,
primary_weapon TEXT,
secondary_weapon TEXT,
best_wave INTEGER,
gg_best_ms INTEGER,
last_seen TEXT);
CREATE TABLE IF NOT EXISTS upgrades(
steamid INTEGER NOT NULL,
stat_key TEXT NOT NULL,
level INTEGER NOT NULL,
PRIMARY KEY(steamid, stat_key));
""");
// match_state is PER-SERVER (composite key) + ephemeral; a one-time drop migrates off the old single-PK schema.
bool matchExists = await c.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='match_state';") > 0;
bool hasServerId = matchExists && await c.ExecuteScalarAsync<long>(
"SELECT COUNT(*) FROM pragma_table_info('match_state') WHERE name='server_id';") > 0;
if (matchExists && !hasServerId) await c.ExecuteAsync("DROP TABLE match_state;");
await c.ExecuteAsync(
"""
CREATE TABLE IF NOT EXISTS match_state(
server_id TEXT NOT NULL DEFAULT 'default',
steamid INTEGER NOT NULL,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
headshot_kills INTEGER NOT NULL DEFAULT 0,
gg_run_started_at INTEGER,
PRIMARY KEY(server_id, steamid));
""");
await TryAddColumnAsync(c, "players", "primary_weapon", "TEXT");
await TryAddColumnAsync(c, "players", "secondary_weapon", "TEXT");
await TryAddColumnAsync(c, "players", "best_wave", "INTEGER");
await TryAddColumnAsync(c, "players", "gg_best_ms", "INTEGER");
await TryAddColumnAsync(c, "match_state", "gg_run_started_at", "INTEGER");
}
private static async Task TryAddColumnAsync(DbConnection c, string table, string col, string type)
{
try { await c.ExecuteAsync($"ALTER TABLE {table} ADD COLUMN {col} {type};"); }
catch (SqliteException) { /* column already exists — fine */ }
}
}
#endif