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)
{
return DenyRead(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyRead(FactionStandingReasonCodes.UnknownFaction);
}
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)
{
return DenyMutation(FactionStandingReasonCodes.PlayerNotWritable);
}
if (faction.Length == 0)
{
return DenyMutation(FactionStandingReasonCodes.UnknownFaction);
}
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(previousStanding + 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);
}
///
public bool TryClearStanding(string playerId, string factionId)
{
var player = FactionStandingIds.NormalizePlayerId(playerId);
var faction = FactionStandingIds.NormalizeFactionId(factionId);
if (player.Length == 0 || faction.Length == 0)
{
return false;
}
if (!factionRegistry.TryGetDefinition(faction, out _))
{
return false;
}
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
if (!PlayerExists(conn, player))
{
return false;
}
using var cmd = new Npgsql.NpgsqlCommand(
"""
DELETE FROM player_faction_standing
WHERE player_id = @pid AND faction_id = @fid;
""",
conn);
cmd.Parameters.AddWithValue("pid", player);
cmd.Parameters.AddWithValue("fid", faction);
cmd.ExecuteNonQuery();
return true;
}
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);
}