namespace NeonSprawl.Server.Game.AbilityInput;
/// PostgreSQL-backed hotbar loadout store keyed by normalized player id.
public sealed class PostgresPlayerHotbarLoadoutStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerHotbarLoadoutStore
{
public bool TryGetBindings(string playerId, out IReadOnlyDictionary bindings)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
bindings = EmptyBindings();
return false;
}
PostgresHotbarLoadoutBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, norm))
{
bindings = EmptyBindings();
return false;
}
bindings = ReadBindings(conn, norm);
return true;
}
public bool TryUpsertBindings(string playerId, IReadOnlyList updates, out IReadOnlyDictionary bindings)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
bindings = EmptyBindings();
return false;
}
PostgresHotbarLoadoutBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var tx = conn.BeginTransaction();
if (!PlayerExists(conn, norm, tx))
{
tx.Rollback();
bindings = EmptyBindings();
return false;
}
foreach (var update in updates)
{
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_hotbar_loadout (player_id, slot_index, ability_id)
VALUES (@pid, @slot, @ability)
ON CONFLICT (player_id, slot_index)
DO UPDATE SET ability_id = EXCLUDED.ability_id, updated_at = now();
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", norm);
cmd.Parameters.AddWithValue("slot", update.SlotIndex);
cmd.Parameters.AddWithValue("ability", update.AbilityId);
cmd.ExecuteNonQuery();
}
bindings = ReadBindings(conn, norm, tx);
tx.Commit();
return true;
}
private static IReadOnlyDictionary ReadBindings(
Npgsql.NpgsqlConnection conn,
string playerIdNormalized,
Npgsql.NpgsqlTransaction? tx = null)
{
var map = new Dictionary();
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT slot_index, ability_id
FROM player_hotbar_loadout
WHERE player_id = @pid
ORDER BY slot_index;
""",
conn,
tx);
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
map[reader.GetInt32(0)] = reader.GetString(1);
}
return map;
}
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
{
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 IReadOnlyDictionary EmptyBindings() => new Dictionary();
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
}