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.ToArray();
}
///
public bool TryClearForInstance(string contractInstanceId)
{
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (instanceId.Length == 0)
{
return false;
}
var idsToRemove = rowsById.Values
.Where(row => string.Equals(row.ContractInstanceId, instanceId, StringComparison.Ordinal))
.Select(static row => row.Id)
.ToArray();
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.ToArray(),
GrantedSkillXp = row.GrantedSkillXp.ToArray(),
GrantedReputation = row.GrantedReputation.ToArray(),
};
return true;
}
}