neon-sprawl/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs

294 lines
11 KiB
C#

using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Server-authoritative quest accept, step advance, and complete with reason codes (NEO-117).
/// Objective wiring: E7M1-07 (NEO-118). HTTP: E7M1-08/09 (NEO-119/NEO-120).
/// Telemetry hook sites: E7M1-10 (NEO-121) — comment-only <c>quest_start</c>, <c>quest_step_complete</c>,
/// <c>quest_complete</c>, <c>quest_accept_denied</c> for future E9.M1.
/// </summary>
public static class QuestStateOperations
{
/// <summary>
/// Validates quest id and prerequisites, then activates at step 0 when not started.
/// </summary>
public static QuestStateOperationResult TryAccept(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{
return DenyAccept(playerId, questId, QuestStateReasonCodes.UnknownQuest);
}
if (!progressStore.CanWritePlayer(playerId))
{
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.UnknownPlayer);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
{
if (existing.Status == QuestProgressStatus.Completed)
{
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.AlreadyCompleted, existing);
}
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.AlreadyActive, existing);
}
foreach (var prerequisiteId in definition.PrerequisiteQuestIds)
{
if (!progressStore.TryGetProgress(playerId, prerequisiteId, out var prerequisite) ||
prerequisite.Status != QuestProgressStatus.Completed)
{
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.PrerequisiteIncomplete);
}
}
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
{
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---
// TODO(E9.M1): catalog emit — first activation only (deny paths excluded).
// Planned payload fields: playerId, questId, currentStepIndex (0), optional schemaVersion.
// No ingest or ILogger here (comments-only).
QuestObjectiveWiring.TryProcessInventoryHasItemForQuest(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
QuestObjectiveWiring.TryCompleteStepIfSatisfied(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
{
return Success(refreshed);
}
return Success(snapshot);
}
return DenyActivateFailure(progressStore, playerId, normalizedQuestId, snapshot);
}
/// <summary>
/// Advances an active quest to <paramref name="newStepIndex"/> when within catalog bounds.
/// </summary>
public static QuestStateOperationResult TryAdvanceStep(
string playerId,
string questId,
int newStepIndex,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (existing.Status == QuestProgressStatus.Completed)
{
return Deny(QuestStateReasonCodes.AlreadyCompleted, existing);
}
if (existing.Status != QuestProgressStatus.Active)
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (newStepIndex <= existing.CurrentStepIndex || newStepIndex >= definition.Steps.Count)
{
return Deny(QuestStateReasonCodes.InvalidStepIndex, existing);
}
if (progressStore.TryAdvanceStep(playerId, normalizedQuestId, newStepIndex, out var snapshot))
{
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_step_complete` ---
// TODO(E9.M1): catalog emit — once per committed step advance (chain quests may emit multiple per wiring pass).
// Planned payload fields: playerId, questId, completedStepIndex (prior index), newStepIndex;
// optional step id from catalog at E9.M1 wiring time. No ingest or ILogger here (comments-only).
QuestObjectiveWiring.TryProcessInventoryHasItemForQuest(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
QuestObjectiveWiring.TryCompleteStepIfSatisfied(
playerId,
normalizedQuestId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
{
return Success(refreshed);
}
return Success(snapshot);
}
return DenyAdvanceFailure(progressStore, playerId, normalizedQuestId, snapshot, existing);
}
/// <summary>
/// Marks a quest complete. Idempotent: replays return success with the existing snapshot.
/// </summary>
public static QuestStateOperationResult TryMarkComplete(
string playerId,
string questId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
TimeProvider timeProvider)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out _))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var progress))
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (progress.Status == QuestProgressStatus.Completed)
{
return Success(progress);
}
if (progress.Status != QuestProgressStatus.Active)
{
return Deny(QuestStateReasonCodes.NotActive);
}
if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot))
{
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_complete` ---
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns above without hook.
// Planned payload fields: playerId, questId, completedAt; optional total step count.
// No ingest or ILogger here (comments-only).
return Success(snapshot);
}
if (snapshot?.Status == QuestProgressStatus.Completed)
{
return Success(snapshot);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
afterAttempt.Status == QuestProgressStatus.Completed)
{
return Success(afterAttempt);
}
if (!progressStore.CanWritePlayer(playerId))
{
return Deny(QuestStateReasonCodes.UnknownPlayer);
}
return DenyFromProgressSnapshot(snapshot, QuestStateReasonCodes.NotActive)
?? Deny(QuestStateReasonCodes.UnknownPlayer, progress);
}
private static QuestStateOperationResult DenyActivateFailure(
IPlayerQuestStateStore progressStore,
string playerId,
string questId,
QuestStepState attemptSnapshot) =>
DenyFromProgressSnapshot(attemptSnapshot, QuestStateReasonCodes.AlreadyActive, playerId, questId)
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.AlreadyActive, playerId, questId)
: null)
?? DenyAccept(playerId, questId, QuestStateReasonCodes.UnknownPlayer);
private static QuestStateOperationResult DenyAdvanceFailure(
IPlayerQuestStateStore progressStore,
string playerId,
string questId,
QuestStepState attemptSnapshot,
QuestStepState priorRead) =>
DenyFromProgressSnapshot(attemptSnapshot, QuestStateReasonCodes.InvalidStepIndex)
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.InvalidStepIndex)
: null)
?? Deny(QuestStateReasonCodes.UnknownPlayer, priorRead);
private static QuestStateOperationResult? DenyFromProgressSnapshot(
QuestStepState? snapshot,
string activeOrIntermediateReasonCode,
string? acceptDenyPlayerId = null,
string? acceptDenyQuestId = null)
{
if (snapshot is null)
{
return null;
}
if (snapshot.Status == QuestProgressStatus.Completed)
{
return acceptDenyPlayerId is not null
? DenyAccept(acceptDenyPlayerId, acceptDenyQuestId ?? string.Empty, QuestStateReasonCodes.AlreadyCompleted, snapshot)
: Deny(QuestStateReasonCodes.AlreadyCompleted, snapshot);
}
if (snapshot.Status == QuestProgressStatus.Active)
{
return acceptDenyPlayerId is not null
? DenyAccept(acceptDenyPlayerId, acceptDenyQuestId ?? string.Empty, activeOrIntermediateReasonCode, snapshot)
: Deny(activeOrIntermediateReasonCode, snapshot);
}
return null;
}
private static QuestStateOperationResult Success(QuestStepState snapshot) =>
new(Success: true, ReasonCode: null, Snapshot: snapshot);
private static QuestStateOperationResult DenyAccept(
string playerId,
string questId,
string reasonCode,
QuestStepState? snapshot = null)
{
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_accept_denied` ---
// TODO(E9.M1): catalog emit — reasonCode from QuestStateReasonCodes (unknown_quest, prerequisite_incomplete, etc.).
// Planned payload fields: playerId, questId, reasonCode (caller context threaded for E9.M1 wiring).
// No ingest or ILogger here (comments-only).
return Deny(reasonCode, snapshot);
}
private static QuestStateOperationResult Deny(string reasonCode, QuestStepState? snapshot = null) =>
new(Success: false, ReasonCode: reasonCode, Snapshot: snapshot);
}