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);
}