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(); } }