204 lines
7.4 KiB
C#
204 lines
7.4 KiB
C#
using System.Text.Json;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
|
|
namespace NeonSprawl.Server.Game.Contracts;
|
|
|
|
/// <summary>PostgreSQL-backed append-only contract outcome audit log (NEO-146).</summary>
|
|
public sealed class PostgresContractOutcomeStore(Npgsql.NpgsqlDataSource dataSource) : IContractOutcomeStore
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
};
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<ContractOutcomeRow> 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<ContractOutcomeRow>();
|
|
using var reader = cmd.ExecuteReader();
|
|
while (reader.Read())
|
|
{
|
|
rows.Add(ReadRow(reader));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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<string>(4)),
|
|
DeserializeSkillXp(reader.GetFieldValue<string>(5)),
|
|
DeserializeReputation(reader.GetFieldValue<string>(6)),
|
|
reader.GetFieldValue<DateTimeOffset>(7));
|
|
|
|
private static string SerializeGrants<T>(IReadOnlyList<T> grants) =>
|
|
JsonSerializer.Serialize(grants, JsonOptions);
|
|
|
|
private static IReadOnlyList<RewardItemGrantApplied> DeserializeItems(string json)
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<RewardItemGrantApplied[]>(json, JsonOptions);
|
|
return parsed ?? [];
|
|
}
|
|
|
|
private static IReadOnlyList<RewardSkillXpGrantApplied> DeserializeSkillXp(string json)
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<RewardSkillXpGrantApplied[]>(json, JsonOptions);
|
|
return parsed ?? [];
|
|
}
|
|
|
|
private static IReadOnlyList<RewardReputationGrantApplied> DeserializeReputation(string json)
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<RewardReputationGrantApplied[]>(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;
|
|
}
|
|
}
|