namespace NeonSprawl.Server.Game.Factions; /// PostgreSQL-backed faction standing keyed by normalized player id + faction id (NEO-135). public sealed class PostgresFactionStandingStore( Npgsql.NpgsqlDataSource dataSource, IFactionDefinitionRegistry factionRegistry) : IFactionStandingStore { /// public bool CanWritePlayer(string playerId) { var player = FactionStandingIds.NormalizePlayerId(playerId); if (player.Length == 0) { return false; } PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); return PlayerExists(conn, player); } /// public FactionStandingReadOutcome TryGetStanding(string playerId, string factionId) { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); if (player.Length == 0 || faction.Length == 0) { return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); } if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) { return DenyRead(FactionStandingReasonCodes.UnknownFaction); } PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); if (!PlayerExists(conn, player)) { return DenyRead(FactionStandingReasonCodes.PlayerNotWritable); } using var cmd = new Npgsql.NpgsqlCommand( """ SELECT standing FROM player_faction_standing WHERE player_id = @pid AND faction_id = @fid LIMIT 1; """, conn); cmd.Parameters.AddWithValue("pid", player); cmd.Parameters.AddWithValue("fid", faction); var scalar = cmd.ExecuteScalar(); var raw = scalar is null or DBNull ? 0 : scalar is int xi ? xi : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); return new FactionStandingReadOutcome(true, null, FactionStandingIds.ClampStanding(raw, definition)); } /// public FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount) { var player = FactionStandingIds.NormalizePlayerId(playerId); var faction = FactionStandingIds.NormalizeFactionId(factionId); if (player.Length == 0 || faction.Length == 0) { return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); } if (!factionRegistry.TryGetDefinition(faction, out var definition) || definition is null) { return DenyMutation(FactionStandingReasonCodes.UnknownFaction); } PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource); using var conn = dataSource.OpenConnection(); using var tx = conn.BeginTransaction(); if (!PlayerExists(conn, player, tx)) { tx.Rollback(); return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable); } var raw = 0; using (var sel = new Npgsql.NpgsqlCommand( """ SELECT standing FROM player_faction_standing WHERE player_id = @pid AND faction_id = @fid LIMIT 1 FOR UPDATE; """, conn, tx)) { sel.Parameters.AddWithValue("pid", player); sel.Parameters.AddWithValue("fid", faction); var scalar = sel.ExecuteScalar(); raw = scalar is null or DBNull ? 0 : scalar is int xi ? xi : Convert.ToInt32(scalar, System.Globalization.CultureInfo.InvariantCulture); } var previousStanding = FactionStandingIds.ClampStanding(raw, definition); var newStanding = FactionStandingIds.ClampStanding(raw + deltaAmount, definition); using var upsert = new Npgsql.NpgsqlCommand( """ INSERT INTO player_faction_standing (player_id, faction_id, standing, updated_at) VALUES (@pid, @fid, @standing, now()) ON CONFLICT (player_id, faction_id) DO UPDATE SET standing = EXCLUDED.standing, updated_at = now(); """, conn, tx); upsert.Parameters.AddWithValue("pid", player); upsert.Parameters.AddWithValue("fid", faction); upsert.Parameters.AddWithValue("standing", newStanding); upsert.ExecuteNonQuery(); tx.Commit(); return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding); } 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 FactionStandingReadOutcome DenyRead(string reasonCode) => new(false, reasonCode, 0); private static FactionStandingMutationOutcome DenyMutation(string reasonCode) => new(false, reasonCode, 0, 0); }