namespace NeonSprawl.Server.Game.Mastery; /// PostgreSQL-backed perk state keyed by normalized player id (NEO-47). public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerPerkStateStore { /// public bool CanWritePlayer(string playerId) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return false; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var cmd = new Npgsql.NpgsqlCommand( "SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;", conn); cmd.Parameters.AddWithValue("pid", norm); return cmd.ExecuteScalar() is not null; } /// public PerkStateSnapshot GetSnapshot(string playerId) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return PerkStateSnapshot.Empty; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); var branchPicks = new Dictionary>(StringComparer.Ordinal); using (var pickCmd = new Npgsql.NpgsqlCommand( """ SELECT skill_id, tier_index, branch_id FROM player_mastery_branch_pick WHERE player_id = @pid; """, conn)) { pickCmd.Parameters.AddWithValue("pid", norm); using var reader = pickCmd.ExecuteReader(); while (reader.Read()) { var skillId = reader.GetString(0); var tierIndex = reader.GetInt32(1); var branchId = reader.GetString(2); if (!branchPicks.TryGetValue(skillId, out var tiers)) { tiers = new Dictionary(); branchPicks[skillId] = tiers; } tiers[tierIndex] = branchId; } } var bySkill = new Dictionary>(StringComparer.Ordinal); foreach (var (skillId, tiers) in branchPicks) { bySkill[skillId] = tiers; } var unlocked = new HashSet(StringComparer.Ordinal); using (var perkCmd = new Npgsql.NpgsqlCommand( """ SELECT perk_id FROM player_unlocked_perk WHERE player_id = @pid; """, conn)) { perkCmd.Parameters.AddWithValue("pid", norm); using var reader = perkCmd.ExecuteReader(); while (reader.Read()) { unlocked.Add(reader.GetString(0)); } } return new PerkStateSnapshot(bySkill, unlocked); } /// public bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId) { var norm = NormalizePlayerId(playerId); var sid = skillId.Trim(); var bid = branchId.Trim(); if (norm.Length == 0 || sid.Length == 0 || bid.Length == 0 || tierIndex < 1) { return false; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } using (var exists = new Npgsql.NpgsqlCommand( """ SELECT 1 FROM player_mastery_branch_pick WHERE player_id = @pid AND skill_id = @sid AND tier_index = @tier LIMIT 1; """, conn, tx)) { exists.Parameters.AddWithValue("pid", norm); exists.Parameters.AddWithValue("sid", sid); exists.Parameters.AddWithValue("tier", tierIndex); if (exists.ExecuteScalar() is not null) { tx.Rollback(); return false; } } using var insert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at) VALUES (@pid, @sid, @tier, @bid, now()); """, conn, tx); insert.Parameters.AddWithValue("pid", norm); insert.Parameters.AddWithValue("sid", sid); insert.Parameters.AddWithValue("tier", tierIndex); insert.Parameters.AddWithValue("bid", bid); insert.ExecuteNonQuery(); tx.Commit(); return true; } /// public bool TryAddUnlockedPerks(string playerId, IEnumerable perkIds) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return false; } var toAdd = new List(); foreach (var raw in perkIds) { var pid = raw.Trim(); if (pid.Length > 0) { toAdd.Add(pid); } } if (toAdd.Count == 0) { return true; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } foreach (var perkId in toAdd) { using var upsert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at) VALUES (@pid, @perk, now()) ON CONFLICT (player_id, perk_id) DO NOTHING; """, conn, tx); upsert.Parameters.AddWithValue("pid", norm); upsert.Parameters.AddWithValue("perk", perkId); upsert.ExecuteNonQuery(); } tx.Commit(); return true; } /// public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex) { var norm = NormalizePlayerId(playerId); var sid = skillId.Trim(); if (norm.Length == 0 || sid.Length == 0 || tierIndex < 1) { return false; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var cmd = new Npgsql.NpgsqlCommand( """ DELETE FROM player_mastery_branch_pick WHERE player_id = @pid AND skill_id = @sid AND tier_index = @tier; """, conn); cmd.Parameters.AddWithValue("pid", norm); cmd.Parameters.AddWithValue("sid", sid); cmd.Parameters.AddWithValue("tier", tierIndex); return cmd.ExecuteNonQuery() > 0; } /// public bool TryResetPerkState(string playerId) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return false; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } using (var picks = new Npgsql.NpgsqlCommand( "DELETE FROM player_mastery_branch_pick WHERE player_id = @pid;", conn, tx)) { picks.Parameters.AddWithValue("pid", norm); picks.ExecuteNonQuery(); } using (var perks = new Npgsql.NpgsqlCommand( "DELETE FROM player_unlocked_perk WHERE player_id = @pid;", conn, tx)) { perks.Parameters.AddWithValue("pid", norm); perks.ExecuteNonQuery(); } tx.Commit(); return true; } /// public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot) { var norm = NormalizePlayerId(playerId); if (norm.Length == 0) { return false; } PostgresPerkStateBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, norm, tx)) { tx.Rollback(); return false; } using (var picks = new Npgsql.NpgsqlCommand( "DELETE FROM player_mastery_branch_pick WHERE player_id = @pid;", conn, tx)) { picks.Parameters.AddWithValue("pid", norm); picks.ExecuteNonQuery(); } using (var perks = new Npgsql.NpgsqlCommand( "DELETE FROM player_unlocked_perk WHERE player_id = @pid;", conn, tx)) { perks.Parameters.AddWithValue("pid", norm); perks.ExecuteNonQuery(); } foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId) { foreach (var (tierIndex, branchId) in tiers) { using var insert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at) VALUES (@pid, @sid, @tier, @bid, now()); """, conn, tx); insert.Parameters.AddWithValue("pid", norm); insert.Parameters.AddWithValue("sid", skillId); insert.Parameters.AddWithValue("tier", tierIndex); insert.Parameters.AddWithValue("bid", branchId); insert.ExecuteNonQuery(); } } foreach (var perkId in snapshot.UnlockedPerkIds) { using var upsert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at) VALUES (@pid, @perk, now()); """, conn, tx); upsert.Parameters.AddWithValue("pid", norm); upsert.Parameters.AddWithValue("perk", perkId); 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(); } }