134 lines
4.1 KiB
C#
134 lines
4.1 KiB
C#
using System.Collections.Concurrent;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
|
|
namespace NeonSprawl.Server.Game.Contracts;
|
|
|
|
/// <summary>Thread-safe in-memory append-only contract outcome audit log (NEO-146).</summary>
|
|
public sealed class InMemoryContractOutcomeStore : IContractOutcomeStore
|
|
{
|
|
private readonly ConcurrentDictionary<string, ContractOutcomeRow> rowsById = new(StringComparer.Ordinal);
|
|
|
|
private readonly ConcurrentDictionary<string, string> rowIdByIdempotencyKey = new(StringComparer.Ordinal);
|
|
|
|
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<ContractOutcomeRow> GetOutcomesForInstance(string contractInstanceId, int? limit = null)
|
|
{
|
|
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
|
if (instanceId.Length == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
IEnumerable<ContractOutcomeRow> 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();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
}
|
|
}
|