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 = [];
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 TryRemoveUnlockedPerks(string playerId, IEnumerable perkIds)
{
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;
}
foreach (var raw in perkIds)
{
var perkId = raw.Trim();
if (perkId.Length == 0)
{
continue;
}
using var delete = new Npgsql.NpgsqlCommand(
"DELETE FROM player_unlocked_perk WHERE player_id = @pid AND perk_id = @perk;",
conn,
tx);
delete.Parameters.AddWithValue("pid", norm);
delete.Parameters.AddWithValue("perk", perkId);
delete.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();
}
}