Merge pull request #156 from ViPro-Technologies/NEO-117-quest-state-operations
NEO-117: QuestStateOperations (accept, step advance, complete)pull/157/head
commit
749b83d0cc
|
|
@ -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
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
|
@ -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 117–118) 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.
|
||||
|
|
@ -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>());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue