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); return cmd.ExecuteNonQuery() == 1; } /// 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 IReadOnlyList DeserializeItems(string json) { var parsed = JsonSerializer.Deserialize(json, JsonOptions); return parsed ?? []; } private static IReadOnlyList DeserializeSkillXp(string json) { var parsed = JsonSerializer.Deserialize(json, JsonOptions); return parsed ?? []; } private static IReadOnlyList 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.ToArray(), GrantedSkillXp = row.GrantedSkillXp.ToArray(), GrantedReputation = row.GrantedReputation.ToArray(), }; return true; } }