254 lines
8.1 KiB
C#
254 lines
8.1 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;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool TryClearProgress(string playerId, string questId)
|
|
{
|
|
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()))
|
|
{
|
|
_ = byKey.TryRemove(key, out _);
|
|
return true;
|
|
}
|
|
}
|
|
}
|