initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
187
Outnumbered/Data/DapperPlayerRepository.cs
Normal file
187
Outnumbered/Data/DapperPlayerRepository.cs
Normal 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);
|
||||
}
|
||||
25
Outnumbered/Data/IPlayerRepository.cs
Normal file
25
Outnumbered/Data/IPlayerRepository.cs
Normal 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();
|
||||
}
|
||||
78
Outnumbered/Data/Models.cs
Normal file
78
Outnumbered/Data/Models.cs
Normal 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;
|
||||
}
|
||||
58
Outnumbered/Data/NpgsqlRepository.cs
Normal file
58
Outnumbered/Data/NpgsqlRepository.cs
Normal 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;
|
||||
""");
|
||||
}
|
||||
}
|
||||
82
Outnumbered/Data/SqliteRepository.cs
Normal file
82
Outnumbered/Data/SqliteRepository.cs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue