using NeonSprawl.Server.Game.Items; namespace NeonSprawl.Server.Game.Quests; /// /// 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 quest_start, quest_step_complete, /// quest_complete, quest_accept_denied for future E9.M1. /// public static class QuestStateOperations { /// /// Validates quest id and prerequisites, then activates at step 0 when not started. /// 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); } /// /// Advances an active quest to when within catalog bounds. /// 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); } /// /// Marks a quest complete. Idempotent: replays return success with the existing snapshot. /// 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); }