Merge pull request #156 from ViPro-Technologies/NEO-117-quest-state-operations

NEO-117: QuestStateOperations (accept, step advance, complete)
pull/157/head
VinPropane 2026-06-06 19:40:42 -04:00 committed by GitHub
commit 749b83d0cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 991 additions and 11 deletions

View File

@ -7,7 +7,7 @@
| **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; runtime from **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; objective wiring from **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose
@ -82,7 +82,9 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
**HTTP read model (NEO-115):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs in `Game/Quests/` ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`.
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). HTTP read/accept deferred to E7M1-08/09; **`QuestStateOperations`** in E7M1-06.
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116).
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP read/accept deferred to E7M1-08/09; objective wiring in E7M1-07.
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.

File diff suppressed because one or more lines are too long

View File

@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|---|---|---|---|---|---|
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); register row stays **Planned** until quest runtime (E7M1-06+) lands.
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); register row stays **Planned** until E7M1-07+ objective wiring and client capstone.
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -214,9 +214,9 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria**
- [ ] Accept fails when prerequisite quest not **completed**.
- [ ] Complete is idempotent per player+quest.
- [ ] Reason codes documented in `server/README.md`.
- [x] Accept fails when prerequisite quest not **completed**.
- [x] Complete is idempotent per player+quest.
- [x] Reason codes documented in `server/README.md`.
**Client counterpart:** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) exposes accept POST to Godot.

View File

@ -0,0 +1,154 @@
# NEO-117 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-117 |
| **Title** | E7M1-06: QuestStateOperations (accept, step advance, complete) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-117/e7m1-06-queststateoperations-accept-step-advance-complete |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-06** |
| **Branch** | `NEO-117-quest-state-operations` |
| **Precursor** | [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) — `IPlayerQuestStateStore` + Postgres (**Done** on `main`) |
| **Pattern** | [NEO-104](NEO-104-implementation-plan.md) / [NEO-105](NEO-105-implementation-plan.md) — store mutations + static `*Operations` with `*ReasonCodes` + `*Result` envelope |
| **Blocks** | [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) — quest objective wiring; [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — accept POST |
| **Client counterpart** | [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — `POST /game/players/{id}/quests/{questId}/accept` (E7M1-09); player-visible accept feedback via [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122). Bruno-only accept verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
## Kickoff clarifications
**No clarifications needed.** [E7M1-06](E7M1-prototype-backlog.md#e7m1-06--queststateoperations-accept-step-advance-complete) backlog, Linear AC, and landed [NEO-116](NEO-116-implementation-plan.md) store contracts settle scope:
- Accept denials use backlog reason codes (`unknown_quest`, `prerequisite_incomplete`, `already_completed`, `already_active`).
- Complete is **idempotent at the operations layer** (second call → `Success: true`, unchanged snapshot) — distinct from encounter completion which denies `already_completed`; matches E7M1 kickoff “once per player per quest id complete” and E7M1-06 backlog “second complete returns success without state change.”
- Store `TryMarkComplete` replay still returns `false`; operations short-circuit on existing `Completed` rows before calling the store.
- `TryAdvanceStep` validates quest id via `IQuestDefinitionRegistry` and step bounds against `QuestDefRow.Steps.Count`; no terminal-step gate on `TryMarkComplete` this story (E7M1-07 decides when to call complete).
- Static `QuestStateOperations` class (no new DI registration), mirroring `EncounterCompletionOperations`.
## Goal, scope, and out-of-scope
**Goal:** Server-authoritative quest lifecycle operations — **accept**, **step advance**, and **complete** — with structured **`reasonCode`** denials. Prerequisite enforcement from catalog **`prerequisiteQuestIds`**. Idempotent complete per `(playerId, questId)`.
**In scope (from Linear + [E7M1-06](E7M1-prototype-backlog.md#e7m1-06--queststateoperations-accept-step-advance-complete)):**
- **`QuestStateReasonCodes`** — stable string constants for accept, advance, and complete paths.
- **`QuestStateOperationResult`** — `Success`, optional `ReasonCode`, optional `QuestStepState` snapshot (when applicable).
- **`QuestStateOperations`** (static):
- **`TryAccept`** — registry validation, prerequisite gate, delegate to `IPlayerQuestStateStore.TryActivate`.
- **`TryAdvanceStep`** — registry validation, active-row + step-index bounds, delegate to store `TryAdvanceStep`.
- **`TryMarkComplete`** — registry validation, idempotent success when already completed, delegate to store `TryMarkComplete`.
- Unit tests (AAA): accept order, prerequisite deny, duplicate accept/active/completed denials, advance bounds, idempotent complete.
- `server/README.md` — quest operations + reason-code table (accept/advance/complete).
**Out of scope (from Linear + backlog):**
- Gather/craft/encounter objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) / E7M1-07).
- HTTP **`GET …/quest-progress`** ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)) and accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)).
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) / E7M1-10).
- Bruno, Godot, dev fixture quest reset API.
- Reward grants / E7.M2 bundles.
- Objective-satisfaction checks before advance/complete (E7M1-07).
## Acceptance criteria checklist
- [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; E7.M1 alignment register, module doc, dependency register note, E7M1-06 backlog checkboxes.
## Technical approach
1. **`QuestStateReasonCodes`** (`Game/Quests/`)
- **Accept:** `unknown_quest`, `prerequisite_incomplete`, `already_completed`, `already_active`, `unknown_player`.
- **Advance:** `unknown_quest`, `not_active`, `invalid_step_index`, `already_completed`, `unknown_player`.
- **Complete:** `unknown_quest`, `not_active`, `unknown_player`.
- (No `already_completed` deny on complete — idempotent success instead.)
2. **`QuestStateOperationResult`**
- `readonly record struct` with `(bool Success, string? ReasonCode, QuestStepState? Snapshot)`.
- Deny helper returns `Success: false`, non-null `ReasonCode`, snapshot when useful (e.g. current row on `already_active`).
3. **`QuestStateOperations.TryAccept`**
- `IQuestDefinitionRegistry.TryNormalizeKnown` + `TryGetDefinition` → else `unknown_quest`.
- `IPlayerQuestStateStore.TryGetProgress`:
- `Completed` → deny `already_completed` (return snapshot).
- `Active` → deny `already_active` (return snapshot).
- For each id in `definition.PrerequisiteQuestIds`: `TryGetProgress` must succeed with `Status == Completed`; else `prerequisite_incomplete`.
- `TryActivate` → on success return snapshot; on failure (unknown player bucket) → `unknown_player`.
4. **`QuestStateOperations.TryAdvanceStep`**
- Registry gate → `unknown_quest`; load `QuestDefRow` for step count.
- Progress read: missing or not `Active``not_active`; `Completed``already_completed`.
- Reject when `newStepIndex <= currentStepIndex` or `newStepIndex >= definition.Steps.Count``invalid_step_index`.
- Store `TryAdvanceStep` → success or `unknown_player` on store denial.
5. **`QuestStateOperations.TryMarkComplete`**
- Registry gate → `unknown_quest`.
- If progress exists and `Completed`**`Success: true`**, return existing snapshot (idempotent; do not call store).
- If missing or not `Active``not_active`.
- `TryMarkComplete(playerId, questId, timeProvider.GetUtcNow(), …)` → success with snapshot; store replay handled by pre-check above.
6. **Tests**
- Use `InMemoryWebApplicationFactory` to resolve real `IQuestDefinitionRegistry` + `IPlayerQuestStateStore` (dev player seeded).
- Frozen ids via `PrototypeE7M1QuestCatalogRules` (`GatherIntroQuestId`, `ChainQuestId`; inline `prototype_quest_refine_intro` / `prototype_quest_combat_intro` for prerequisite matrix).
- Prerequisite scenario: accept refine intro without gather complete → deny; complete gather via store + operations → accept refine succeeds.
- Idempotent complete: two `TryMarkComplete` calls → both `Success: true`, same `CompletedAt`.
7. **Docs**
- Extend `server/README.md` quest section with **`QuestStateOperations`** summary and reason-code table.
- Module alignment register update deferred until story lands (same as NEO-116).
### Operation flow (accept)
```mermaid
flowchart TD
A[TryAccept] --> B{Known quest?}
B -->|no| UQ[deny unknown_quest]
B -->|yes| C{Progress row?}
C -->|completed| AC[deny already_completed]
C -->|active| AA[deny already_active]
C -->|missing| D{All prerequisites completed?}
D -->|no| PI[deny prerequisite_incomplete]
D -->|yes| E[TryActivate]
E -->|true| OK[Success + snapshot]
E -->|false| UP[deny unknown_player]
```
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Quests/QuestStateReasonCodes.cs` | Stable deny reason strings for accept/advance/complete. |
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperationResult.cs` | Operations result envelope (`Success`, `ReasonCode`, `Snapshot`). |
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Static `TryAccept`, `TryAdvanceStep`, `TryMarkComplete`. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | AAA unit tests for accept prerequisites, denials, advance bounds, idempotent complete. |
| `docs/plans/NEO-117-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — E7M1-06 / NEO-117 operations landed. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | NEO-117 operations bullet + README link. |
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-117 landed. |
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-06 acceptance checkboxes checked. |
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs` | Add `RefineIntroQuestId` and `CombatIntroQuestId` test constants. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `QuestStateOperationsTests.cs` | **Accept:** gather intro happy path (`not_started` → active). **Accept:** refine intro denies `prerequisite_incomplete` before gather complete. **Accept:** refine succeeds after gather marked complete via operations. **Accept:** `unknown_quest`, `already_active`, `already_completed` denials. **Accept:** unknown player → `unknown_player`. **Advance:** chain quest step 0→1 clears counters path via operations. **Advance:** `invalid_step_index` (non-increasing, past last step). **Advance:** `not_active`, `already_completed`. **Complete:** first mark success; second mark idempotent success with unchanged `CompletedAt`. **Complete:** `not_active` when never accepted. **Host:** factory resolves registry + store for integration-style smoke. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Idempotent complete vs encounter deny pattern** | Operations return `Success: true` on replay (E7M1-06 backlog); store layer unchanged. | **adopted** |
| **Terminal step required before complete** | Defer to E7M1-07; operations do not validate objective satisfaction or terminal step index in NEO-117. | **adopted** |
| **Extra catalog test constants** | Optional `RefineIntroQuestId` / `CombatIntroQuestId` on `PrototypeE7M1QuestCatalogRules`; inline strings acceptable if only used in one test file. | **adopted** |
| **Prerequisite check strictness** | Require `TryGetProgress` + `Completed` for each prerequisite id (implicit `not_started` fails gate). | **adopted** |

View File

@ -0,0 +1,73 @@
# Code review — NEO-117 (E7M1-06)
**Date:** 2026-06-06
**Scope:** Branch `NEO-117-quest-state-operations` vs `origin/main` — commits `44ef5f2``f053ffb`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-117 adds **`QuestStateOperations`** — static accept, step advance, and complete wrappers over **`IPlayerQuestStateStore`** with **`QuestStateReasonCodes`** and **`QuestStateOperationResult`**, mirroring the NEO-104/105 encounter store + operations pattern. Prerequisite enforcement reads catalog **`prerequisiteQuestIds`**; complete is **idempotent at the operations layer** (replay → `Success: true` with unchanged snapshot), including a defensive re-read after store denial for concurrent complete. Fifteen AAA tests cover accept prerequisites/denials, advance bounds, idempotent complete, and host DI smoke via **`InMemoryWebApplicationFactory`**. **`server/README.md`** documents operations and reason codes. Server-only infrastructure — correct scope; HTTP and objective wiring remain deferred. Risk is low; main follow-ups are test helper consistency, minor coverage gaps, and post-merge decomposition register updates.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-117-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-06) | **Matches** — E7M1-06 acceptance checkboxes checked. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-117 operations bullet + README link added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-06 / NEO-117 landed note appended. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-117 note appended. |
| `server/README.md` | **Matches** — quest operations section, reason-code table, idempotent complete semantics, HTTP deferral to NEO-119/NEO-120. |
| Full-stack epic decomposition | **N/A** — server-only; client counterpart explicitly NEO-120/NEO-122; plan does not claim prototype slice complete. |
**Register / tracking:** E7.M1 register row and module **Status** should gain an **E7M1-06 / NEO-117 landed** bullet after merge (register stays **Planned** until E7M1-07+ runtime and client capstone).
## Blocking issues
(none)
## Bugbot follow-up (2026-06-06)
1. ~~**TryActivate failure misclassified** — Map store mutation failures from returned snapshots / re-read progress instead of always `unknown_player`.~~ **Done.** `DenyActivateFailure` / `DenyAdvanceFailure` helpers; store replay on `TryMarkComplete` returns idempotent success.
2. ~~**Unknown player wrong deny code** — Check `CanWritePlayer` before prerequisite gate so unknown players get `unknown_player`, not `prerequisite_incomplete`.~~ **Done.** Added `IPlayerQuestStateStore.CanWritePlayer` (in-memory + Postgres); `TryAccept` calls it before prerequisites.
## Suggestions
1. ~~**Test `ResolveDependencies` pattern** — `QuestStateOperationsTests.ResolveDependencies` creates an `IServiceScope` that is never disposed. **`EncounterCompletionOperationsTests`** resolves from `factory.Services` root (singleton stores). Align to avoid undisposed scopes and match the established encounter test pattern.~~ **Done.** `ResolveDependencies` and host smoke test resolve from `factory.Services`.
2. ~~**`unknown_quest` coverage on advance/complete** — Accept path has `TryAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog`; advance and complete lack parity. Add two small `[Fact]` cases (mirror **`EncounterCompletionOperationsTests`** unknown-encounter test) for registry gate consistency.~~ **Done.** Added `TryAdvanceStep_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog` and `TryMarkComplete_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog`.
3. ~~**Post-merge decomposition docs** — Plan defers register/module updates; on story end append **E7M1-06 / NEO-117** to `documentation_and_implementation_alignment.md`, `E7_M1_QuestStateMachine.md` (operations bullet + README link), and check E7M1-06 AC boxes in `E7M1-prototype-backlog.md`.~~ **Done.** Alignment register, module doc, dependency register note, and E7M1-06 backlog checkboxes updated.
## Nits
- ~~Nit: **`TryAccept`** progress switch default `_ => Deny(UnknownQuest)` is unreachable while **`QuestProgressStatus`** is only `Active`/`Completed`; if kept for exhaustiveness, a more accurate code (or `NotActive`) would aid future enum extensions.~~ **Done.** Replaced switch with explicit `Completed` / `AlreadyActive` branches.
- ~~Nit: Chain-quest setup tests hardcode **`prototype_quest_combat_intro`** and **`prototype_quest_refine_intro`**; plan deferred **`RefineIntroQuestId`** / **`CombatIntroQuestId`** constants — acceptable for now, but NEO-118 wiring will reuse these ids.~~ **Done.** Added **`RefineIntroQuestId`** and **`CombatIntroQuestId`** to **`PrototypeE7M1QuestCatalogRules`**; tests updated.
- ~~Nit: **`TryMarkComplete`** re-calls **`TryGetProgress`** after the active-row read (lines 117118) where `active` could be reused on the `not_active` deny path — minor readability only.~~ **Done.** Single **`TryGetProgress`** read at start of **`TryMarkComplete`**.
## Verification
```bash
# Quest state operations (primary signal for this story)
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~QuestStateOperationsTests"
# Quest store regression (precursor)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~InMemoryPlayerQuestStateStoreTests|FullyQualifiedName~PlayerQuestProgressPersistence"
# Full server suite (CI)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
**Reviewer note:** All 15 `QuestStateOperationsTests` and full server suite (663 tests) passed locally during review.
**Manual:** None required — no HTTP or player-visible surface in this story.

View File

@ -0,0 +1,461 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.PositionState;
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 = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
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_WhenPrerequisiteQuestNotWritable()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAccept(
UnknownPlayerId,
RefineQuestId,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.UnknownPlayer, result.ReasonCode);
}
[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, CombatQuestId, 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, CombatQuestId, 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_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryAdvanceStep(
PlayerId,
"prototype_quest_missing",
1,
deps.QuestRegistry,
deps.ProgressStore);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.UnknownQuest, result.ReasonCode);
}
[Fact]
public async Task TryMarkComplete_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = QuestStateOperations.TryMarkComplete(
PlayerId,
"prototype_quest_missing",
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(QuestStateReasonCodes.UnknownQuest, 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();
var deps = ResolveDependencies(factory);
// Act
var accept = QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore);
var complete = QuestStateOperations.TryMarkComplete(
PlayerId,
GatherQuestId,
deps.QuestRegistry,
deps.ProgressStore,
TimeProvider.System);
// Assert
Assert.True(accept.Success);
Assert.True(complete.Success);
Assert.Equal(QuestProgressStatus.Completed, complete.Snapshot!.Status);
}
[Fact]
public void CanWritePlayer_ShouldReturnFalse_ForUnknownPlayer()
{
// Arrange
var store = new InMemoryPlayerQuestStateStore(
Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }));
// Act
var canWrite = store.CanWritePlayer(UnknownPlayerId);
// Assert
Assert.False(canWrite);
}
[Fact]
public void CanWritePlayer_ShouldReturnTrue_ForDevPlayer()
{
// Arrange
var store = new InMemoryPlayerQuestStateStore(
Microsoft.Extensions.Options.Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }));
// Act
var canWrite = store.CanWritePlayer(PlayerId);
// Assert
Assert.True(canWrite);
}
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 services = factory.Services;
return (
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>());
}
}

View File

@ -6,6 +6,9 @@ namespace NeonSprawl.Server.Game.Quests;
/// </summary>
public interface IPlayerQuestStateStore
{
/// <summary>True when mutations for <paramref name="playerId"/> are allowed (dev bucket or Postgres <c>player_position</c> row).</summary>
bool CanWritePlayer(string playerId);
/// <summary>Missing row ⇒ <c>false</c> (callers treat as <c>not_started</c>).</summary>
bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot);

View File

@ -66,6 +66,13 @@ public sealed class InMemoryPlayerQuestStateStore(IOptions<GamePositionOptions>
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)
{

View File

@ -10,6 +10,20 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var player = QuestProgressIds.NormalizePlayerId(playerId);
if (player.Length == 0)
{
return false;
}
PostgresPlayerQuestProgressBootstrap.EnsureSchema(dataSource);
using var conn = dataSource.OpenConnection();
return PlayerExists(conn, player);
}
/// <inheritdoc />
public bool TryGetProgress(string playerId, string questId, out QuestStepState snapshot)
{

View File

@ -26,6 +26,12 @@ public static class PrototypeE7M1QuestCatalogRules
/// <summary>First frozen onboarding quest id (gather intro).</summary>
public const string GatherIntroQuestId = "prototype_quest_gather_intro";
/// <summary>Refine onboarding quest id (requires gather intro complete).</summary>
public const string RefineIntroQuestId = "prototype_quest_refine_intro";
/// <summary>Combat onboarding quest id (no prerequisites).</summary>
public const string CombatIntroQuestId = "prototype_quest_combat_intro";
/// <summary>First objective id on gather intro step (frozen catalog).</summary>
public const string GatherIntroFirstObjectiveId = "gather_intro_obj_scrap";

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Server-internal quest state resolution envelope (NEO-117).
/// HTTP DTOs via E7M1-08/09 (NEO-119/NEO-120).
/// </summary>
public readonly record struct QuestStateOperationResult(
bool Success,
string? ReasonCode,
QuestStepState? Snapshot);

View File

@ -0,0 +1,206 @@
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).
/// </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)
{
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
{
return Deny(QuestStateReasonCodes.UnknownQuest);
}
if (!progressStore.CanWritePlayer(playerId))
{
return Deny(QuestStateReasonCodes.UnknownPlayer);
}
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
{
if (existing.Status == QuestProgressStatus.Completed)
{
return Deny(QuestStateReasonCodes.AlreadyCompleted, existing);
}
return Deny(QuestStateReasonCodes.AlreadyActive, existing);
}
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 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)
{
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 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))
{
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)
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.AlreadyActive)
: null)
?? Deny(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)
{
if (snapshot is null)
{
return null;
}
if (snapshot.Status == QuestProgressStatus.Completed)
{
return Deny(QuestStateReasonCodes.AlreadyCompleted, snapshot);
}
if (snapshot.Status == QuestProgressStatus.Active)
{
return Deny(activeOrIntermediateReasonCode, snapshot);
}
return null;
}
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);
}

View File

@ -0,0 +1,19 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Stable deny reason codes for <see cref="QuestStateOperations"/> (NEO-117).</summary>
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";
}

View File

@ -158,20 +158,45 @@ 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:**
- **`CanWritePlayer`** — whether mutations are allowed for the player (dev bucket / Postgres `player_position`).
- **`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/`.