using System.Collections.Concurrent; using NeonSprawl.Server.Game.Rewards; namespace NeonSprawl.Server.Game.Contracts; /// Thread-safe in-memory append-only contract outcome audit log (NEO-146). public sealed class InMemoryContractOutcomeStore : IContractOutcomeStore { private readonly ConcurrentDictionary rowsById = new(StringComparer.Ordinal); private readonly ConcurrentDictionary rowIdByIdempotencyKey = new(StringComparer.Ordinal); private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); /// public bool TryAppend(ContractOutcomeRow row) { if (!TryNormalizeRow(row, out var normalized)) { return false; } lock (idLocks.GetOrAdd(normalized.Id, _ => new object())) { if (rowsById.ContainsKey(normalized.Id) || rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey)) { return false; } rowsById[normalized.Id] = normalized; rowIdByIdempotencyKey[normalized.IdempotencyKey] = normalized.Id; return true; } } /// public bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row) { row = null!; var key = idempotencyKey?.Trim(); if (string.IsNullOrEmpty(key)) { return false; } if (!rowIdByIdempotencyKey.TryGetValue(key, out var rowId) || !rowsById.TryGetValue(rowId, out var stored)) { return false; } row = stored; return true; } /// public IReadOnlyList GetOutcomesForInstance(string contractInstanceId, int? limit = null) { var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); if (instanceId.Length == 0) { return []; } IEnumerable query = rowsById.Values .Where(r => string.Equals(r.ContractInstanceId, instanceId, StringComparison.Ordinal)) .OrderBy(static r => r.RecordedAt) .ThenBy(static r => r.Id, StringComparer.Ordinal); if (limit is > 0) { query = query.Take(limit.Value); } return [..query]; } /// public bool TryClearForInstance(string contractInstanceId) { var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId); if (instanceId.Length == 0) { return false; } string[] idsToRemove = [.. rowsById.Values .Where(row => string.Equals(row.ContractInstanceId, instanceId, StringComparison.Ordinal)) .Select(static row => row.Id)]; foreach (var id in idsToRemove) { if (rowsById.TryRemove(id, out var removed)) { rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _); } idLocks.TryRemove(id, out _); } return true; } 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; } }