280 lines
9.5 KiB
C#
280 lines
9.5 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;
|
|
}
|
|
|
|
if (!activeInstanceByPlayer.TryGetValue(player, out var instanceId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
lock (instanceLocks.GetOrAdd(PlayerLockKey(player), _ => new object()))
|
|
{
|
|
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
|
|
row.Status != ContractInstanceStatus.Active ||
|
|
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
|
|
{
|
|
activeInstanceByPlayer.TryRemove(player, out _);
|
|
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(player, out _);
|
|
}
|
|
|
|
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(player, out _);
|
|
snapshot = row.ToSnapshot();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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(player, out _);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|