namespace NeonSprawl.Server.Game.Skills; /// PostgreSQL-backed skill XP totals keyed by normalized player id (NEO-38). public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerSkillProgressionStore { /// public IReadOnlyDictionary GetXpTotals(string playerId) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return new Dictionary(StringComparer.Ordinal); } PostgresSkillProgressionBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var cmd = new Npgsql.NpgsqlCommand( """ SELECT skill_id, xp FROM player_skill_progression WHERE player_id = @pid; """, conn); cmd.Parameters.AddWithValue("pid", norm); var map = new Dictionary(StringComparer.Ordinal); using var reader = cmd.ExecuteReader(); while (reader.Read()) { map[reader.GetString(0)] = reader.GetInt32(1); } return map; } /// public bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp) { previousXp = 0; newXp = 0; var norm = NormalizePlayerId(playerId); var sid = skillId.Trim(); if (norm.Length == 0 || sid.Length == 0) { return false; } PostgresSkillProgressionBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } using (var sel = new Npgsql.NpgsqlCommand( """ SELECT xp FROM player_skill_progression WHERE player_id = @pid AND skill_id = @sid LIMIT 1 FOR UPDATE; """, conn, tx)) { sel.Parameters.AddWithValue("pid", norm); sel.Parameters.AddWithValue("sid", sid); var scalar = sel.ExecuteScalar(); previousXp = scalar is null || Equals(scalar, DBNull.Value) ? 0 : scalar is int xi ? xi : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); } newXp = previousXp + amount; if (newXp < 0) { tx.Rollback(); return false; } using var upsert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at) VALUES (@pid, @sid, @xp, now()) ON CONFLICT (player_id, skill_id) DO UPDATE SET xp = EXCLUDED.xp, updated_at = now(); """, conn, tx); upsert.Parameters.AddWithValue("pid", norm); upsert.Parameters.AddWithValue("sid", sid); upsert.Parameters.AddWithValue("xp", newXp); upsert.ExecuteNonQuery(); tx.Commit(); return true; } /// public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) { if (xp < 0) { return false; } var norm = NormalizePlayerId(playerId); var sid = skillId.Trim(); if (norm.Length == 0 || sid.Length == 0) { return false; } PostgresSkillProgressionBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } if (xp == 0) { using var del = new Npgsql.NpgsqlCommand( """ DELETE FROM player_skill_progression WHERE player_id = @pid AND skill_id = @sid; """, conn, tx); del.Parameters.AddWithValue("pid", norm); del.Parameters.AddWithValue("sid", sid); del.ExecuteNonQuery(); } else { using var upsert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at) VALUES (@pid, @sid, @xp, now()) ON CONFLICT (player_id, skill_id) DO UPDATE SET xp = EXCLUDED.xp, updated_at = now(); """, conn, tx); upsert.Parameters.AddWithValue("pid", norm); upsert.Parameters.AddWithValue("sid", sid); upsert.Parameters.AddWithValue(nameof(xp), xp); upsert.ExecuteNonQuery(); } tx.Commit(); return true; } /// public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary skillXpTotals) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return false; } foreach (var (skillId, xp) in skillXpTotals) { if (skillId.Trim().Length == 0 || xp < 0) { return false; } } PostgresSkillProgressionBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } foreach (var (skillId, xp) in skillXpTotals) { var sid = skillId.Trim(); if (xp == 0) { using var del = new Npgsql.NpgsqlCommand( """ DELETE FROM player_skill_progression WHERE player_id = @pid AND skill_id = @sid; """, conn, tx); del.Parameters.AddWithValue("pid", norm); del.Parameters.AddWithValue("sid", sid); del.ExecuteNonQuery(); } else { using var upsert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at) VALUES (@pid, @sid, @xp, now()) ON CONFLICT (player_id, skill_id) DO UPDATE SET xp = EXCLUDED.xp, updated_at = now(); """, conn, tx); upsert.Parameters.AddWithValue("pid", norm); upsert.Parameters.AddWithValue("sid", sid); upsert.Parameters.AddWithValue(nameof(xp), xp); upsert.ExecuteNonQuery(); } } tx.Commit(); return true; } private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx) { using var cmd = new Npgsql.NpgsqlCommand( "SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;", conn, tx); cmd.Parameters.AddWithValue("pid", playerIdNormalized); return cmd.ExecuteScalar() is not null; } private static string NormalizePlayerId(string? playerId) { var t = playerId?.Trim(); if (string.IsNullOrEmpty(t)) { return string.Empty; } return t.ToLowerInvariant(); } }