336 lines
12 KiB
C#
336 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using Microsoft.Extensions.Options;
|
|
using NeonSprawl.Server.Game.PositionState;
|
|
|
|
namespace NeonSprawl.Server.Game.Contracts;
|
|
|
|
/// <summary>Thread-safe in-memory contract instances; seeds the configured dev player (NEO-146).</summary>
|
|
public sealed class InMemoryContractInstanceStore(IOptions<GamePositionOptions> options) : IContractInstanceStore
|
|
{
|
|
private sealed class InstanceRow(
|
|
string contractInstanceId,
|
|
string templateId,
|
|
string playerId,
|
|
ContractInstanceStatus status,
|
|
string seedBucket,
|
|
DateTimeOffset issuedAt,
|
|
DateTimeOffset? completedAt)
|
|
{
|
|
public string ContractInstanceId { get; } = contractInstanceId;
|
|
|
|
public string TemplateId { get; } = templateId;
|
|
|
|
public string PlayerId { get; } = playerId;
|
|
|
|
public ContractInstanceStatus Status { get; private set; } = status;
|
|
|
|
public string SeedBucket { get; } = seedBucket;
|
|
|
|
public DateTimeOffset IssuedAt { get; } = issuedAt;
|
|
|
|
public DateTimeOffset? CompletedAt { get; private set; } = completedAt;
|
|
|
|
public ContractInstanceState ToSnapshot() =>
|
|
new(ContractInstanceId, TemplateId, PlayerId, Status, SeedBucket, IssuedAt, CompletedAt);
|
|
|
|
public void MarkCompleted(DateTimeOffset completedAt)
|
|
{
|
|
Status = ContractInstanceStatus.Completed;
|
|
CompletedAt = completedAt;
|
|
}
|
|
}
|
|
|
|
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
|
|
|
|
private readonly ConcurrentDictionary<string, InstanceRow> byInstanceId = new(StringComparer.Ordinal);
|
|
|
|
private readonly ConcurrentDictionary<string, string> activeInstanceByPlayer = new(StringComparer.Ordinal);
|
|
|
|
private readonly ConcurrentDictionary<string, object> instanceLocks = new(StringComparer.Ordinal);
|
|
|
|
private static string PlayerLockKey(string player) => $"{player}\0active";
|
|
|
|
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
|
|
{
|
|
var id = ContractInstanceIds.NormalizePlayerId(o.DevPlayerId);
|
|
if (id.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
|
}
|
|
|
|
return new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool CanWritePlayer(string playerId)
|
|
{
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
return player.Length > 0 && knownPlayers.Contains(player);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryGet(string playerId, string contractInstanceId, out ContractInstanceState snapshot)
|
|
{
|
|
snapshot = null!;
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
|
if (player.Length == 0 || instanceId.Length == 0 || !knownPlayers.Contains(player))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var lockKey = ContractInstanceIds.MakeInstanceKey(player, instanceId);
|
|
if (lockKey.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(lockKey, _ => new object()))
|
|
{
|
|
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
|
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
snapshot = row.ToSnapshot();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryGetActiveForPlayer(string playerId, out ContractInstanceState snapshot)
|
|
{
|
|
snapshot = null!;
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
if (player.Length == 0 || !knownPlayers.Contains(player))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
|
{
|
|
if (!activeInstanceByPlayer.TryGetValue(player, out var instanceId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
|
row.Status != ContractInstanceStatus.Active ||
|
|
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
|
{
|
|
activeInstanceByPlayer.TryRemove(new KeyValuePair<string, string>(player, instanceId));
|
|
return false;
|
|
}
|
|
|
|
snapshot = row.ToSnapshot();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryCreateActive(
|
|
string playerId,
|
|
string contractInstanceId,
|
|
string templateId,
|
|
string seedBucket,
|
|
DateTimeOffset issuedAt,
|
|
out ContractInstanceState snapshot)
|
|
{
|
|
snapshot = null!;
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
|
var normalizedTemplateId = ContractInstanceIds.NormalizeTemplateId(templateId);
|
|
var normalizedSeedBucket = ContractInstanceIds.NormalizeSeedBucket(seedBucket);
|
|
if (player.Length == 0 ||
|
|
instanceId.Length == 0 ||
|
|
normalizedTemplateId.Length == 0 ||
|
|
normalizedSeedBucket.Length == 0 ||
|
|
!knownPlayers.Contains(player))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
|
{
|
|
lock (instanceLocks.GetOrAdd(ContractInstanceIds.MakeInstanceKey(player, instanceId), _ => new object()))
|
|
{
|
|
if (activeInstanceByPlayer.TryGetValue(player, out var existingActiveId))
|
|
{
|
|
if (byInstanceId.TryGetValue(existingActiveId, out var existingActive) &&
|
|
existingActive.Status == ContractInstanceStatus.Active)
|
|
{
|
|
snapshot = existingActive.ToSnapshot();
|
|
return false;
|
|
}
|
|
|
|
activeInstanceByPlayer.TryRemove(new KeyValuePair<string, string>(player, existingActiveId));
|
|
}
|
|
|
|
if (byInstanceId.TryGetValue(instanceId, out var existing))
|
|
{
|
|
snapshot = existing.ToSnapshot();
|
|
return false;
|
|
}
|
|
|
|
var row = new InstanceRow(
|
|
instanceId,
|
|
normalizedTemplateId,
|
|
player,
|
|
ContractInstanceStatus.Active,
|
|
normalizedSeedBucket,
|
|
issuedAt,
|
|
null);
|
|
byInstanceId[instanceId] = row;
|
|
activeInstanceByPlayer[player] = instanceId;
|
|
snapshot = row.ToSnapshot();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryMarkComplete(
|
|
string playerId,
|
|
string contractInstanceId,
|
|
DateTimeOffset completedAt,
|
|
out ContractInstanceState snapshot)
|
|
{
|
|
snapshot = null!;
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
|
if (player.Length == 0 || instanceId.Length == 0 || !knownPlayers.Contains(player))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var lockKey = ContractInstanceIds.MakeInstanceKey(player, instanceId);
|
|
if (lockKey.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
|
{
|
|
lock (instanceLocks.GetOrAdd(lockKey, _ => new object()))
|
|
{
|
|
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
|
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (row.Status == ContractInstanceStatus.Completed)
|
|
{
|
|
snapshot = row.ToSnapshot();
|
|
return false;
|
|
}
|
|
|
|
if (row.Status != ContractInstanceStatus.Active)
|
|
{
|
|
snapshot = row.ToSnapshot();
|
|
return false;
|
|
}
|
|
|
|
row.MarkCompleted(completedAt);
|
|
activeInstanceByPlayer.TryRemove(new KeyValuePair<string, string>(player, instanceId));
|
|
snapshot = row.ToSnapshot();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<ContractInstanceState> ListForPlayer(string playerId, int maxCompletedRows)
|
|
{
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
if (player.Length == 0 || !knownPlayers.Contains(player) || maxCompletedRows < 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
|
{
|
|
ContractInstanceState? active = null;
|
|
if (activeInstanceByPlayer.TryGetValue(player, out var activeId) &&
|
|
byInstanceId.TryGetValue(activeId, out var activeRow) &&
|
|
activeRow.Status == ContractInstanceStatus.Active &&
|
|
string.Equals(activeRow.PlayerId, player, StringComparison.Ordinal))
|
|
{
|
|
active = activeRow.ToSnapshot();
|
|
}
|
|
|
|
var completed = new List<ContractInstanceState>();
|
|
foreach (var row in byInstanceId.Values)
|
|
{
|
|
if (!string.Equals(row.PlayerId, player, StringComparison.Ordinal) ||
|
|
row.Status != ContractInstanceStatus.Completed)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
completed.Add(row.ToSnapshot());
|
|
}
|
|
|
|
completed.Sort(static (a, b) =>
|
|
{
|
|
var cmp = b.IssuedAt.CompareTo(a.IssuedAt);
|
|
return cmp != 0
|
|
? cmp
|
|
: string.CompareOrdinal(a.ContractInstanceId, b.ContractInstanceId);
|
|
});
|
|
|
|
if (completed.Count > maxCompletedRows)
|
|
{
|
|
completed.RemoveRange(maxCompletedRows, completed.Count - maxCompletedRows);
|
|
}
|
|
|
|
if (active is null)
|
|
{
|
|
return completed;
|
|
}
|
|
|
|
var result = new List<ContractInstanceState>(1 + completed.Count) { active };
|
|
result.AddRange(completed);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryClearInstance(string playerId, string contractInstanceId)
|
|
{
|
|
var player = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
|
|
if (player.Length == 0 || instanceId.Length == 0 || !knownPlayers.Contains(player))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var lockKey = ContractInstanceIds.MakeInstanceKey(player, instanceId);
|
|
if (lockKey.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
|
{
|
|
lock (instanceLocks.GetOrAdd(lockKey, _ => new object()))
|
|
{
|
|
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
|
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
_ = byInstanceId.TryRemove(instanceId, out _);
|
|
if (row.Status == ContractInstanceStatus.Active)
|
|
{
|
|
activeInstanceByPlayer.TryRemove(new KeyValuePair<string, string>(player, instanceId));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|