using System.Text.Json;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Contracts;
/// PostgreSQL-backed append-only contract outcome audit log (NEO-146).
public sealed class PostgresContractOutcomeStore(Npgsql.NpgsqlDataSource dataSource) : IContractOutcomeStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
///
public bool TryAppend(ContractOutcomeRow row)
{
if (!TryNormalizeRow(row, out var normalized))
{
return false;
}
PostgresContractOutcomeBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
INSERT INTO contract_outcome (
id, contract_instance_id, player_id, idempotency_key,
granted_items, granted_skill_xp, granted_reputation, recorded_at)
SELECT @id, @instance_id, @pid, @key, @items::jsonb, @skill_xp::jsonb, @rep::jsonb, @recorded
WHERE NOT EXISTS (
SELECT 1 FROM contract_outcome
WHERE id = @id OR idempotency_key = @key);
""",
conn);
cmd.Parameters.AddWithValue("id", normalized.Id);
cmd.Parameters.AddWithValue("instance_id", normalized.ContractInstanceId);
cmd.Parameters.AddWithValue("pid", normalized.PlayerId);
cmd.Parameters.AddWithValue("key", normalized.IdempotencyKey);
cmd.Parameters.AddWithValue("items", SerializeGrants(normalized.GrantedItems));
cmd.Parameters.AddWithValue("skill_xp", SerializeGrants(normalized.GrantedSkillXp));
cmd.Parameters.AddWithValue("rep", SerializeGrants(normalized.GrantedReputation));
cmd.Parameters.AddWithValue("recorded", normalized.RecordedAt);
try
{
return cmd.ExecuteNonQuery() == 1;
}
catch (Npgsql.PostgresException ex) when (ex.SqlState == Npgsql.PostgresErrorCodes.ForeignKeyViolation)
{
return false;
}
}
///
public bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row)
{
row = null!;
var key = idempotencyKey?.Trim();
if (string.IsNullOrEmpty(key))
{
return false;
}
PostgresContractOutcomeBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
SELECT id, contract_instance_id, player_id, idempotency_key,
granted_items, granted_skill_xp, granted_reputation, recorded_at
FROM contract_outcome
WHERE idempotency_key = @key
LIMIT 1;
""",
conn);
cmd.Parameters.AddWithValue("key", key);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
{
return false;
}
row = ReadRow(reader);
return true;
}
///
public IReadOnlyList GetOutcomesForInstance(string contractInstanceId, int? limit = null)
{
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (instanceId.Length == 0)
{
return [];
}
PostgresContractOutcomeBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
var sql = limit is > 0
? """
SELECT id, contract_instance_id, player_id, idempotency_key,
granted_items, granted_skill_xp, granted_reputation, recorded_at
FROM contract_outcome
WHERE contract_instance_id = @instance_id
ORDER BY recorded_at ASC, id ASC
LIMIT @lim;
"""
: """
SELECT id, contract_instance_id, player_id, idempotency_key,
granted_items, granted_skill_xp, granted_reputation, recorded_at
FROM contract_outcome
WHERE contract_instance_id = @instance_id
ORDER BY recorded_at ASC, id ASC;
""";
using var cmd = new Npgsql.NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("instance_id", instanceId);
if (limit is > 0)
{
cmd.Parameters.AddWithValue("lim", limit.Value);
}
var rows = new List();
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
rows.Add(ReadRow(reader));
}
return rows;
}
///
public bool TryClearForInstance(string contractInstanceId)
{
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (instanceId.Length == 0)
{
return false;
}
PostgresContractOutcomeBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
using var cmd = new Npgsql.NpgsqlCommand(
"""
DELETE FROM contract_outcome
WHERE contract_instance_id = @instance_id;
""",
conn);
cmd.Parameters.AddWithValue("instance_id", instanceId);
cmd.ExecuteNonQuery();
return true;
}
private static ContractOutcomeRow ReadRow(Npgsql.NpgsqlDataReader reader) =>
new(
reader.GetString(0),
reader.GetString(1),
reader.GetString(2),
reader.GetString(3),
DeserializeItems(reader.GetFieldValue(4)),
DeserializeSkillXp(reader.GetFieldValue(5)),
DeserializeReputation(reader.GetFieldValue(6)),
reader.GetFieldValue(7));
private static string SerializeGrants(IReadOnlyList grants) =>
JsonSerializer.Serialize(grants, JsonOptions);
private static RewardItemGrantApplied[] DeserializeItems(string json)
{
var parsed = JsonSerializer.Deserialize(json, JsonOptions);
return parsed ?? [];
}
private static RewardSkillXpGrantApplied[] DeserializeSkillXp(string json)
{
var parsed = JsonSerializer.Deserialize(json, JsonOptions);
return parsed ?? [];
}
private static RewardReputationGrantApplied[] DeserializeReputation(string json)
{
var parsed = JsonSerializer.Deserialize(json, JsonOptions);
return parsed ?? [];
}
private static bool TryNormalizeRow(ContractOutcomeRow row, out ContractOutcomeRow normalized)
{
normalized = null!;
var id = row.Id.Trim();
var playerId = ContractInstanceIds.NormalizePlayerId(row.PlayerId);
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(row.ContractInstanceId);
var idempotencyKey = row.IdempotencyKey.Trim();
if (id.Length == 0 ||
playerId.Length == 0 ||
instanceId.Length == 0 ||
idempotencyKey.Length == 0)
{
return false;
}
normalized = row with
{
Id = id,
PlayerId = playerId,
ContractInstanceId = instanceId,
IdempotencyKey = idempotencyKey,
GrantedItems = [..row.GrantedItems],
GrantedSkillXp = [..row.GrantedSkillXp],
GrantedReputation = [..row.GrantedReputation],
};
return true;
}
}