neon-sprawl/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgress...

180 lines
5.6 KiB
C#

namespace NeonSprawl.Server.Game.Skills;
/// <summary>PostgreSQL-backed skill XP totals keyed by normalized player id (NEO-38).</summary>
public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerSkillProgressionStore
{
/// <inheritdoc />
public IReadOnlyDictionary<string, int> GetXpTotals(string playerId)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return new Dictionary<string, int>(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<string, int>(StringComparer.Ordinal);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
map[reader.GetString(0)] = reader.GetInt32(1);
}
return map;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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("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();
}
}