393 lines
12 KiB
C#
393 lines
12 KiB
C#
namespace NeonSprawl.Server.Game.Mastery;
|
|
|
|
/// <summary>PostgreSQL-backed perk state keyed by normalized player id (NEO-47).</summary>
|
|
public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerPerkStateStore
|
|
{
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<string, Dictionary<int, string>>(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<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);
|
|
foreach (var (skillId, tiers) in branchPicks)
|
|
{
|
|
bySkill[skillId] = tiers;
|
|
}
|
|
|
|
var unlocked = new HashSet<string>(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);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)
|
|
{
|
|
var norm = NormalizePlayerId(playerId);
|
|
if (norm.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var toAdd = new List<string>();
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|