From f053ffbb398dc94d33a069ed3c2c1fae8d1e0434 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 6 Jun 2026 19:26:37 -0400 Subject: [PATCH] NEO-117: Add QuestStateOperations with reason codes and tests. Server-authoritative accept, step advance, and idempotent complete wrapping IPlayerQuestStateStore with prerequisite enforcement. --- docs/plans/NEO-117-implementation-plan.md | 12 +- .../Game/Quests/QuestStateOperationsTests.cs | 378 ++++++++++++++++++ .../Game/Quests/QuestStateOperationResult.cs | 10 + .../Game/Quests/QuestStateOperations.cs | 142 +++++++ .../Game/Quests/QuestStateReasonCodes.cs | 19 + server/README.md | 32 +- 6 files changed, 586 insertions(+), 7 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestStateOperationResult.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs diff --git a/docs/plans/NEO-117-implementation-plan.md b/docs/plans/NEO-117-implementation-plan.md index 0606930..be4c898 100644 --- a/docs/plans/NEO-117-implementation-plan.md +++ b/docs/plans/NEO-117-implementation-plan.md @@ -50,9 +50,15 @@ ## Acceptance criteria checklist -- [ ] Accept fails when prerequisite quest not **completed** (`prerequisite_incomplete`). -- [ ] Complete is idempotent per player+quest (`TryMarkComplete` second call → `Success: true`, same `CompletedAt`). -- [ ] Reason codes documented in `server/README.md`. +- [x] Accept fails when prerequisite quest not **completed** (`prerequisite_incomplete`). +- [x] Complete is idempotent per player+quest (`TryMarkComplete` second call → `Success: true`, same `CompletedAt`). +- [x] Reason codes documented in `server/README.md`. + +## Implementation reconciliation (shipped) + +- **Types:** `QuestStateReasonCodes`, `QuestStateOperationResult`, `QuestStateOperations` (`TryAccept`, `TryAdvanceStep`, `TryMarkComplete`). +- **Tests:** `QuestStateOperationsTests` — 15 AAA cases (accept prerequisites, denials, advance bounds, idempotent complete, host DI smoke). +- **Docs:** `server/README.md` quest operations + reason-code table. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs new file mode 100644 index 0000000..8ccc0f6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs @@ -0,0 +1,378 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public sealed class QuestStateOperationsTests +{ + private const string PlayerId = "dev-local-1"; + private const string UnknownPlayerId = "unknown-player-xyz"; + private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId; + private const string RefineQuestId = "prototype_quest_refine_intro"; + private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId; + private const string ChainObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId; + + [Fact] + public async Task TryAccept_ShouldActivateGatherIntro_WhenNotStarted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = QuestStateOperations.TryAccept( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.NotNull(result.Snapshot); + Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); + Assert.Equal(0, result.Snapshot.CurrentStepIndex); + } + + [Fact] + public async Task TryAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = QuestStateOperations.TryAccept( + PlayerId, + RefineQuestId, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, result.ReasonCode); + Assert.Null(result.Snapshot); + } + + [Fact] + public async Task TryAccept_ShouldSucceedRefineIntro_WhenGatherIntroCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + Assert.True(QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider).Success); + + // Act + var result = QuestStateOperations.TryAccept( + PlayerId, + RefineQuestId, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); + } + + [Fact] + public async Task TryAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = QuestStateOperations.TryAccept( + PlayerId, + "prototype_quest_missing", + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode); + } + + [Fact] + public async Task TryAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + + // Act + var result = QuestStateOperations.TryAccept( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.AlreadyActive, result.ReasonCode); + Assert.NotNull(result.Snapshot); + Assert.Equal(QuestProgressStatus.Active, result.Snapshot!.Status); + } + + [Fact] + public async Task TryAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + Assert.True(QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider).Success); + + // Act + var result = QuestStateOperations.TryAccept( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode); + Assert.Equal(QuestProgressStatus.Completed, result.Snapshot!.Status); + } + + [Fact] + public async Task TryAccept_ShouldDenyUnknownPlayer_WhenPlayerNotInStore() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = QuestStateOperations.TryAccept( + UnknownPlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode); + } + + [Fact] + public async Task TryAdvanceStep_ShouldAdvanceChainQuestAndClearCounters() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + AcceptAndComplete(deps, GatherQuestId, timeProvider); + AcceptAndComplete(deps, RefineQuestId, timeProvider); + AcceptAndComplete(deps, "prototype_quest_combat_intro", timeProvider); + Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainObjectiveId, 5, out _)); + + // Act + var result = QuestStateOperations.TryAdvanceStep( + PlayerId, + ChainQuestId, + 1, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.True(result.Success); + Assert.Null(result.ReasonCode); + Assert.Equal(1, result.Snapshot!.CurrentStepIndex); + Assert.Empty(result.Snapshot.ObjectiveCounters); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + public async Task TryAdvanceStep_ShouldDenyInvalidStepIndex_WhenIndexNotIncreasingOrPastLastStep(int newStepIndex) + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + AcceptAndComplete(deps, GatherQuestId, timeProvider); + AcceptAndComplete(deps, RefineQuestId, timeProvider); + AcceptAndComplete(deps, "prototype_quest_combat_intro", timeProvider); + Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + + // Act + var result = QuestStateOperations.TryAdvanceStep( + PlayerId, + ChainQuestId, + newStepIndex, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.InvalidStepIndex, result.ReasonCode); + } + + [Fact] + public async Task TryAdvanceStep_ShouldDenyNotActive_WhenQuestNotAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + + // Act + var result = QuestStateOperations.TryAdvanceStep( + PlayerId, + GatherQuestId, + 1, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode); + } + + [Fact] + public async Task TryAdvanceStep_ShouldDenyAlreadyCompleted_WhenQuestCompleted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + Assert.True(QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider).Success); + + // Act + var result = QuestStateOperations.TryAdvanceStep( + PlayerId, + GatherQuestId, + 1, + deps.QuestRegistry, + deps.ProgressStore); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, result.ReasonCode); + } + + [Fact] + public async Task TryMarkComplete_ShouldBeIdempotentAtOperationsLayer() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success); + + // Act + var first = QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider); + var second = QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider); + + // Assert + Assert.True(first.Success); + Assert.Null(first.ReasonCode); + Assert.NotNull(first.Snapshot!.CompletedAt); + Assert.True(second.Success); + Assert.Null(second.ReasonCode); + Assert.Equal(first.Snapshot.CompletedAt, second.Snapshot!.CompletedAt); + Assert.Equal(QuestProgressStatus.Completed, second.Snapshot.Status); + } + + [Fact] + public async Task TryMarkComplete_ShouldDenyNotActive_WhenQuestNeverAccepted() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var timeProvider = TimeProvider.System; + + // Act + var result = QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider); + + // Assert + Assert.False(result.Success); + Assert.Equal(QuestStateReasonCodes.NotActive, result.ReasonCode); + } + + [Fact] + public async Task Host_ShouldResolveRegistryAndStore_ForQuestStateOperations() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var scope = factory.Services.CreateScope(); + var questRegistry = scope.ServiceProvider.GetRequiredService(); + var progressStore = scope.ServiceProvider.GetRequiredService(); + + // Act + var accept = QuestStateOperations.TryAccept(PlayerId, GatherQuestId, questRegistry, progressStore); + var complete = QuestStateOperations.TryMarkComplete( + PlayerId, + GatherQuestId, + questRegistry, + progressStore, + TimeProvider.System); + + // Assert + Assert.True(accept.Success); + Assert.True(complete.Success); + Assert.Equal(QuestProgressStatus.Completed, complete.Snapshot!.Status); + } + + private static void AcceptAndComplete( + (IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) deps, + string questId, + TimeProvider timeProvider) + { + Assert.True(QuestStateOperations.TryAccept(PlayerId, questId, deps.QuestRegistry, deps.ProgressStore).Success); + Assert.True(QuestStateOperations.TryMarkComplete( + PlayerId, + questId, + deps.QuestRegistry, + deps.ProgressStore, + timeProvider).Success); + } + + private static (IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) ResolveDependencies( + InMemoryWebApplicationFactory factory) + { + var scope = factory.Services.CreateScope(); + return ( + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateOperationResult.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateOperationResult.cs new file mode 100644 index 0000000..ede7fad --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateOperationResult.cs @@ -0,0 +1,10 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Server-internal quest state resolution envelope (NEO-117). +/// HTTP DTOs via E7M1-08/09 (NEO-119/NEO-120). +/// +public readonly record struct QuestStateOperationResult( + bool Success, + string? ReasonCode, + QuestStepState? Snapshot); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs new file mode 100644 index 0000000..ef23447 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs @@ -0,0 +1,142 @@ +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). +/// +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) + { + 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 existing.Status switch + { + QuestProgressStatus.Completed => Deny(QuestStateReasonCodes.AlreadyCompleted, existing), + QuestProgressStatus.Active => Deny(QuestStateReasonCodes.AlreadyActive, existing), + _ => Deny(QuestStateReasonCodes.UnknownQuest), + }; + } + + foreach (var prerequisiteId in definition.PrerequisiteQuestIds) + { + if (!progressStore.TryGetProgress(playerId, prerequisiteId, out var prerequisite) || + prerequisite.Status != QuestProgressStatus.Completed) + { + return Deny(QuestStateReasonCodes.PrerequisiteIncomplete); + } + } + + if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot)) + { + return Success(snapshot); + } + + return Deny(QuestStateReasonCodes.UnknownPlayer); + } + + /// + /// Advances an active quest to when within catalog bounds. + /// + public static QuestStateOperationResult TryAdvanceStep( + string playerId, + string questId, + int newStepIndex, + IQuestDefinitionRegistry questRegistry, + IPlayerQuestStateStore progressStore) + { + 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)) + { + return Success(snapshot); + } + + return Deny(QuestStateReasonCodes.UnknownPlayer, 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 existing) && + existing.Status == QuestProgressStatus.Completed) + { + return Success(existing); + } + + if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var active) || + active.Status != QuestProgressStatus.Active) + { + return Deny(QuestStateReasonCodes.NotActive); + } + + if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot)) + { + return Success(snapshot); + } + + if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) && + afterAttempt.Status == QuestProgressStatus.Completed) + { + return Success(afterAttempt); + } + + return Deny(QuestStateReasonCodes.UnknownPlayer); + } + + private static QuestStateOperationResult Success(QuestStepState snapshot) => + new(Success: true, ReasonCode: null, Snapshot: snapshot); + + private static QuestStateOperationResult Deny(string reasonCode, QuestStepState? snapshot = null) => + new(Success: false, ReasonCode: reasonCode, Snapshot: snapshot); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs b/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs new file mode 100644 index 0000000..44eb53f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs @@ -0,0 +1,19 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Stable deny reason codes for (NEO-117). +public static class QuestStateReasonCodes +{ + public const string UnknownQuest = "unknown_quest"; + + public const string PrerequisiteIncomplete = "prerequisite_incomplete"; + + public const string AlreadyCompleted = "already_completed"; + + public const string AlreadyActive = "already_active"; + + public const string UnknownPlayer = "unknown_player"; + + public const string NotActive = "not_active"; + + public const string InvalidStepIndex = "invalid_step_index"; +} diff --git a/server/README.md b/server/README.md index a4e4b07..c45c63c 100644 --- a/server/README.md +++ b/server/README.md @@ -158,20 +158,44 @@ curl -sS -i "http://localhost:5253/game/world/quest-definitions" ## Quest progress store (NEO-116) -Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Mutations are server-authoritative — HTTP read/accept routes land in E7M1-08/09 ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)+ for **`QuestStateOperations`**). +Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by **`(playerId, questId)`**. A missing row means **`not_started`**; **`TryActivate`** creates an **`active`** row at step index **0** with empty objective counters. Game code should call **`QuestStateOperations`** (NEO-117) for accept/advance/complete — not the store directly — so quest ids and prerequisites are validated. HTTP read/accept routes land in E7M1-08/09 (NEO-119/NEO-120). -**Interface methods:** +**Store interface methods:** - **`TryGetProgress`** — read one row; false when not started. - **`TryActivate`** — first accept (`not_started` → `active`). - **`TryAdvanceStep`** — bump step index and clear counters for the new step (denies when completed or index does not increase). - **`TryUpdateObjectiveCounter`** — set one objective counter on the current step (non-negative). -- **`TryMarkComplete`** — idempotent completion flag + **`completedAt`** timestamp. +- **`TryMarkComplete`** — first completion returns `true`; store replay returns `false` without changing **`completedAt`**. -Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids should be validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** at operation/HTTP layers (NEO-117+). +Completed rows cannot regress to active without a reset API (none in prototype). Player ids are normalized (trim + lowercase). Quest ids are validated via **`IQuestDefinitionRegistry.TryNormalizeKnown`** in **`QuestStateOperations`**. **Storage:** in-memory singleton when **`ConnectionStrings:NeonSprawl`** is unset (seeds configured dev player only). When Postgres is configured, **`PostgresPlayerQuestStateStore`** persists to **`player_quest_progress`** ([`V008__player_quest_progress.sql`](../db/migrations/V008__player_quest_progress.sql)) with **`objective_counters`** JSONB for the current step. Plan: [NEO-116 implementation plan](../../docs/plans/NEO-116-implementation-plan.md). Bruno startup smoke: `bruno/neon-sprawl-server/quest-progress/` (health only until E7M1-08 HTTP). +### Quest state operations (NEO-117) + +**`QuestStateOperations`** (static) wraps the store with catalog validation and structured **`reasonCode`** denials. Returns **`QuestStateOperationResult`** (`success`, optional `reasonCode`, optional progress snapshot). + +| Method | Role | +|--------|------| +| **`TryAccept`** | Validates quest id + **`prerequisiteQuestIds`** (each prerequisite must be **completed**), then activates at step 0. | +| **`TryAdvanceStep`** | Requires active row; **`newStepIndex`** must be greater than current and less than step count from **`QuestDefRow`**. | +| **`TryMarkComplete`** | Marks active quest complete. **Idempotent:** second call returns **`success: true`** with unchanged snapshot (unlike encounter completion). | + +**Reason codes:** + +| Code | When | +|------|------| +| **`unknown_quest`** | Quest id not in catalog (accept, advance, complete). | +| **`prerequisite_incomplete`** | Accept when a listed prerequisite is not **completed** (implicit `not_started` fails). | +| **`already_completed`** | Accept or advance when row is already **completed**. | +| **`already_active`** | Accept when row is already **active**. | +| **`unknown_player`** | Player id cannot be written (no dev bucket / missing from Postgres `player_position`). | +| **`not_active`** | Advance or complete when quest was never accepted or is not **active**. | +| **`invalid_step_index`** | Advance when index does not increase or is past the last step. | + +Objective wiring (gather/craft/encounter hooks) is E7M1-07 ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)). Plan: [NEO-117 implementation plan](../../docs/plans/NEO-117-implementation-plan.md). + ## Encounter definitions (NEO-103) **`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.