neon-sprawl/server/NeonSprawl.Server/Game/Quests/InMemoryPlayerQuestStateSto...

236 lines
7.6 KiB
C#

using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Thread-safe in-memory quest progress; seeds the configured dev player (NEO-116).</summary>
public sealed class InMemoryPlayerQuestStateStore(IOptions<GamePositionOptions> options) : IPlayerQuestStateStore
{
private sealed class ProgressRow(
QuestProgressStatus status,
int currentStepIndex,
Dictionary<string, int> objectiveCounters,
DateTimeOffset? completedAt)
{
public QuestProgressStatus Status { get; private set; } = status;
public int CurrentStepIndex { get; private set; } = currentStepIndex;
public Dictionary<string, int> ObjectiveCounters { get; } = objectiveCounters;
public DateTimeOffset? CompletedAt { get; private set; } = completedAt;
public QuestStepState ToSnapshot(string playerId, string questId) =>
new(
playerId,
questId,
Status,
CurrentStepIndex,
new Dictionary<string, int>(ObjectiveCounters, StringComparer.Ordinal),
CompletedAt);
public void SetStepIndex(int newStepIndex)
{
CurrentStepIndex = newStepIndex;
ObjectiveCounters.Clear();
}
public void SetObjectiveCounter(string objectiveId, int newCount) =>
ObjectiveCounters[objectiveId] = newCount;
public void MarkCompleted(DateTimeOffset completedAt)
{
Status = QuestProgressStatus.Completed;
CompletedAt = completedAt;
}
public static ProgressRow CreateActive() =>
new(QuestProgressStatus.Active, 0, new Dictionary<string, int>(StringComparer.Ordinal), null);
}
private readonly HashSet<string> knownPlayers = CreateKnownPlayers(options.Value);
private readonly ConcurrentDictionary<string, ProgressRow> byKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
private static HashSet<string> CreateKnownPlayers(GamePositionOptions o)
{
var id = QuestProgressIds.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 = QuestProgressIds.NormalizePlayerId(playerId);
return player.Length > 0 && knownPlayers.Contains(player);
}
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row))
{
return false;
}
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryActivate(string playerId, string questId, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (byKey.TryGetValue(key, out var existing))
{
snapshot = existing.ToSnapshot(player, quest);
return false;
}
var row = ProgressRow.CreateActive();
byKey[key] = row;
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryAdvanceStep(string playerId, string questId, int newStepIndex, out QuestStepState snapshot)
{
snapshot = null!;
if (newStepIndex < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row) ||
row.Status != QuestProgressStatus.Active ||
newStepIndex <= row.CurrentStepIndex)
{
if (byKey.TryGetValue(key, out var existing))
{
snapshot = existing.ToSnapshot(player, quest);
}
return false;
}
row.SetStepIndex(newStepIndex);
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryUpdateObjectiveCounter(
string playerId,
string questId,
string objectiveId,
int newCount,
out QuestStepState snapshot)
{
snapshot = null!;
if (newCount < 0)
{
return false;
}
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var objective = QuestProgressIds.NormalizeObjectiveId(objectiveId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || objective.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row) || row.Status != QuestProgressStatus.Active)
{
if (byKey.TryGetValue(key, out var existing))
{
snapshot = existing.ToSnapshot(player, quest);
}
return false;
}
row.SetObjectiveCounter(objective, newCount);
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
/// <inheritdoc />
public bool TryMarkComplete(string playerId, string questId, DateTimeOffset completedAt, out QuestStepState snapshot)
{
snapshot = null!;
var player = QuestProgressIds.NormalizePlayerId(playerId);
var quest = QuestProgressIds.NormalizeQuestId(questId);
var key = QuestProgressIds.MakeProgressKey(player, quest);
if (key.Length == 0 || !knownPlayers.Contains(player))
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row))
{
return false;
}
if (row.Status == QuestProgressStatus.Completed)
{
snapshot = row.ToSnapshot(player, quest);
return false;
}
row.MarkCompleted(completedAt);
snapshot = row.ToSnapshot(player, quest);
return true;
}
}
}