Merge pull request #158 from ViPro-Technologies/NEO-119-get-quest-progress

NEO-119: GET /game/players/{id}/quest-progress
pull/159/head
VinPropane 2026-06-07 12:15:00 -04:00 committed by GitHub
commit c388949b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 836 additions and 9 deletions

View File

@ -0,0 +1,44 @@
meta {
name: GET quest progress default
type: http
seq: 2
}
docs {
NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started.
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
expect(body.quests).to.be.an("array");
expect(body.quests.length).to.equal(4);
});
test("all prototype quests are not_started with empty counters", function () {
const body = res.getBody();
const expectedIds = [
"prototype_quest_combat_intro",
"prototype_quest_gather_intro",
"prototype_quest_operator_chain",
"prototype_quest_refine_intro",
];
const actualIds = body.quests.map((row) => row.questId);
expect(actualIds).to.eql(expectedIds);
for (const row of body.quests) {
expect(row.status).to.equal("not_started");
expect(row.currentStepIndex).to.equal(0);
expect(row.objectiveCounters).to.eql({});
expect(row.completedAt).to.equal(undefined);
}
});
}

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**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; HTTP + client from **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) → 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**; **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) **`QuestObjectiveWiring`** **landed**; **E7M1-08** [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) per-player GET **landed**; accept + client from **E7M1-09** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) → 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
@ -84,10 +84,12 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
**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.
**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 accept deferred to E7M1-09.
**Quest objective wiring (NEO-118):** **`QuestObjectiveWiring`** — gather/craft/encounter success hooks + **`inventory_has_item`** on accept/step advance/inventory mutations; auto advance/complete via **`QuestStateOperations`** ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118).
**Per-player quest progress GET (NEO-119):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; registry + store merge; GET-side **`inventory_has_item`** refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`.
**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.
## Risks and telemetry

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)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); register row stays **Planned** until E7M1-08+ HTTP and client capstone.
**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)); **E7M1-07 / NEO-118** **`QuestObjectiveWiring`** landed ([NEO-118 plan](../../plans/NEO-118-implementation-plan.md)); **E7M1-08 / NEO-119** per-player GET landed ([NEO-119 plan](../../plans/NEO-119-implementation-plan.md)); register row stays **Planned** until E7M1-09+ accept HTTP 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

@ -269,8 +269,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
**Acceptance criteria**
- [ ] GET returns stable v1 envelope for **`dev-local-1`** player.
- [ ] Completed quests show **`completed`** with step count at terminal step.
- [x] GET returns stable v1 envelope for **`dev-local-1`** player.
- [x] Completed quests show **`completed`** with step count at terminal step.
**Client counterpart:** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — **`quest_progress_client.gd`** + HUD labels.

View File

@ -0,0 +1,162 @@
# NEO-119 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-119 |
| **Title** | E7M1-08: GET /game/players/{id}/quest-progress |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-119/e7m1-08-get-gameplayersidquest-progress |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-08** |
| **Branch** | `NEO-119-get-quest-progress` |
| **Precursor** | [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) — `QuestObjectiveWiring` gather/craft/encounter + `inventory_has_item` (**landed on `main`**) |
| **Pattern** | [NEO-108](NEO-108-implementation-plan.md) — registry-backed player GET + position gate + Bruno; [NEO-115](NEO-115-implementation-plan.md) — quest registry ordering |
| **Blocks** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — client quest progress HUD (E7M1-11) |
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — **`quest_progress_client.gd`** + HUD labels poll this GET after gather/craft/encounter mutations (E7M1-11). Bruno-only verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **GET-side `inventory_has_item` re-eval** | Should GET re-run inventory snapshot wiring before building the response? (NEO-118 deferred “GET polling” to NEO-119.) | **Re-eval on GET** — call wiring inventory refresh + step completion pass so HUD reflects pre-held tokens (e.g. chain terminal **`contract_handoff_token`** from combat intro) without another inventory mutation. | **Adopted** — user confirmed |
**Additional defaults (no kickoff question — settled by backlog / precedent):**
- **`status`** strings: `not_started` / `active` / `completed` (NEO-116 implicit missing row → `not_started` at HTTP layer).
- **`objectiveCounters`**: object map `{ objectiveId: count }` — mirrors store JSONB and current-step-only semantics.
- **Player gate:** `IPositionStateStore.TryGetPosition`**404** for unknown/blank ids (encounter-progress / gig-progression precedent).
- **Quest roster:** all catalog quests via **`GetDefinitionsInIdOrder()`**; ordinal `id` order matches NEO-115.
- **Manual QA doc:** skip `docs/manual-qa/NEO-119.md` — server-only; Bruno + API integration tests (NEO-108 / NEO-115 precedent). Accept flows in Bruno wait for [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120).
## Goal, scope, and out-of-scope
**Goal:** Expose authoritative per-player quest progress over HTTP so clients and Bruno can read server-owned status, step index, objective counters, and completion timestamp without local objective math.
**In scope (from Linear + [E7M1-08](E7M1-prototype-backlog.md#e7m1-08--get-gameplayersidquest-progress)):**
- **`GET /game/players/{id}/quest-progress`** with versioned envelope (`schemaVersion` **1**, **`playerId`**, **`quests`** array).
- Per-quest row: **`questId`**, **`status`**, **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`** when **`completed`**.
- Projection from **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`** (missing row ⇒ **`not_started`**).
- **GET hook:** best-effort **`inventory_has_item`** refresh + step auto-advance/complete before snapshot (kickoff decision).
- Known-player gate (position store).
- Bruno `bruno/neon-sprawl-server/quest-progress/` — default read + shape assertions.
- Integration tests (AAA): unknown player 404; default all `not_started`; accept → partial counter → completed terminal step index.
- `server/README.md` route section.
**Out of scope (from Linear + backlog):**
- Quest definition catalog GET ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — already landed).
- **`POST …/quests/{questId}/accept`** ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) / E7M1-09).
- Godot HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)).
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)).
- Dev fixture reset API for quest rows.
- `docs/manual-qa/NEO-119.md` (server-only per kickoff).
## Acceptance criteria checklist
- [x] GET returns stable v1 envelope for **`dev-local-1`** player (four quests, `schemaVersion` 1).
- [x] Completed quests show **`completed`** with **`currentStepIndex`** at terminal step (0-based; e.g. operator chain index **3**).
- [x] Bruno + integration tests pass in CI.
## Implementation reconciliation (shipped)
- **Route:** `GET /game/players/{id}/quest-progress``QuestProgressApi.BuildSnapshot`; 404 via `IPositionStateStore`.
- **GET hook:** `QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer` — inventory refresh + step completion before snapshot.
- **DTOs:** `QuestProgressListResponse` (schema v1); row `status` not_started/active/completed; `objectiveCounters` map; `completedAt` when completed.
- **Tests:** seven AAA cases in `QuestProgressApiTests`.
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru`.
- **Docs:** `server/README.md`; E7.M1 module snapshot + alignment register + backlog updated.
## Technical approach
1. **Route:** **`GET /game/players/{id}/quest-progress`** — inject **`IPositionStateStore`**, **`IQuestDefinitionRegistry`**, **`IPlayerQuestStateStore`**, **`IPlayerInventoryStore`**, **`IItemDefinitionRegistry`**, **`TimeProvider`**.
2. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`EncounterProgressApi`**).
3. **GET-side wiring (kickoff):** Before building JSON, best-effort call a new public helper on **`QuestObjectiveWiring`** (e.g. **`TryRefreshInventoryProgressForPlayer`**) that:
- wraps existing **`RefreshInventoryHasItemCountersForPlayer`**
- then runs the same step-completion sweep as gather/craft paths (**`TryCompleteAllActiveQuests`** — extract from private to shared internal/public entry)
- swallows/logs at debug; never fails the HTTP response
4. **Status derivation** (per catalog quest id):
| Store row | **`status`** | **`currentStepIndex`** | **`objectiveCounters`** | **`completedAt`** |
|-----------|--------------|------------------------|-------------------------|-------------------|
| Missing | `not_started` | `0` | `{}` | omitted |
| Active | `active` | row index | row counters map | omitted |
| Completed | `completed` | terminal index (unchanged at complete) | last-step counters (may be empty after terminal satisfy) | ISO-8601 from row |
5. **Ordering:** Build rows from **`questRegistry.GetDefinitionsInIdOrder()`** — ids match **`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`** ordinal order.
6. **Implementation:** **`QuestProgressApi`** + **`QuestProgressListDtos.cs`** with internal **`BuildSnapshot(playerId, …)`** testable like **`EncounterProgressApi.BuildSnapshot`**. Register **`app.MapQuestProgressApi()`** in **`Program.cs`** after **`MapEncounterProgressApi()`**.
7. **Bruno:** `bruno/neon-sprawl-server/quest-progress/`
- **`Get quest progress default.bru`** — GET **`dev-local-1`** → 200, `schemaVersion` 1, `quests.length === 4`, all `not_started`, empty counters.
- Keep existing health smoke request.
- Accept / partial-progress Bruno flows deferred until NEO-120 POST lands.
8. **Docs:** Add **`server/README.md`** GET section (curl example, GET inventory refresh note). Update NEO-118 README deferral line. Module snapshot / alignment register / E7M1 backlog on story land.
### Expected v1 envelope (prototype default)
```json
{
"schemaVersion": 1,
"playerId": "dev-local-1",
"quests": [
{ "questId": "prototype_quest_combat_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} },
{ "questId": "prototype_quest_gather_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} },
{ "questId": "prototype_quest_operator_chain", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} },
{ "questId": "prototype_quest_refine_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} }
]
}
```
### Wiring flow (GET)
```mermaid
flowchart TD
R[GET quest-progress] --> G{Known player?}
G -->|no| N404[404]
G -->|yes| W[QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer]
W --> B[BuildSnapshot registry + store]
B --> J[JSON v1 response]
```
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | `Map*` extension; player gate; GET inventory refresh hook; `BuildSnapshot`. |
| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Versioned list response + per-quest row DTOs (`status`, counters map). |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | AAA HTTP integration: 404, default not_started, active partial, completed terminal index, GET inventory refresh. |
| `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru` | Bruno default-state verification. |
| `docs/plans/NEO-119-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Public GET-hook entry (`TryRefreshInventoryProgressForPlayer`) sharing refresh + step-completion sweep. |
| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestProgressApi()` after encounter progress API. |
| `server/README.md` | Document GET route, response shape, GET-side `inventory_has_item` refresh; replace E7M1-08 deferral stubs. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | HTTP quest-progress read bullet (on story complete). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-119 HTTP (on story complete). |
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-119 (on story complete). |
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-08 acceptance checkboxes (on story complete). |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `QuestProgressApiTests.cs` | **404:** unknown player + whitespace id. **Default:** GET `dev-local-1` → schema v1, four quests in ordinal id order, all `not_started`, `currentStepIndex` 0, empty `objectiveCounters`. **Active partial:** `QuestStateOperations.TryAccept` gather intro + `TryUpdateObjectiveCounter` → GET shows `active`, counter 2/3 on gather objective. **Completed:** accept + satisfy gather intro via operations/wiring helpers → GET shows `completed`, `currentStepIndex` 0, `completedAt` present. **Chain terminal:** operator chain completed → `currentStepIndex` 3. **GET refresh:** active chain on terminal `inventory_has_item` step with token pre-seeded in inventory (no new grant) → GET triggers complete (side-effecting read). |
| Existing quest test suite | Regression smoke — no behavior change to wiring stores when GET not called. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Side-effecting GET** | Adopted per kickoff — document in README; mirror deferred NEO-118 “polling hook” intent for client HUD | **adopted** |
| **Private `TryCompleteAllActiveQuests`** | Extract shared internal method invoked by GET hook and existing gather/craft/encounter paths | **adopted** |
| **Bruno without accept POST** | Default-state Bruno only this story; accept spine Bruno joins NEO-120 | **adopted** |
| **Completed row counters** | Project store counters as-is (terminal step may show satisfied counts or empty after advance logic) — assert `status` + `currentStepIndex` + `completedAt` in tests | **adopted** |

View File

@ -0,0 +1,65 @@
# Code review — NEO-119 (E7M1-08)
**Date:** 2026-06-07
**Scope:** Branch `NEO-119-get-quest-progress` vs `origin/main` — commits `78e5de3``9e4c52f`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-119 adds **`GET /game/players/{id}/quest-progress`** — a versioned v1 snapshot merging **`IQuestDefinitionRegistry`** catalog rows with **`IPlayerQuestStateStore`** progress (missing row → `not_started`). The handler mirrors **`EncounterProgressApi`**: trim id, position-store gate → **404**, then JSON. Before snapshot build it best-effort calls **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** (inventory refresh + step completion sweep), adopting the kickoff side-effecting GET decision for HUD polling. Seven AAA integration tests cover 404 gates, default `not_started`, active partial counters, completed gather/chain terminal index, and GET-triggered chain completion with a pre-held token. Bruno default-state smoke, **`server/README.md`**, plan reconciliation, and E7.M1 decomposition docs are updated. Server-only slice — client HUD correctly deferred to NEO-122. Risk is low; no blocking correctness or contract issues found.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-119-implementation-plan.md` | **Matches** — kickoff GET refresh adopted; acceptance checklist checked; reconciliation section accurate. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-08) | **Matches** — acceptance checkboxes checked; client counterpart NEO-122 noted. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-119 GET bullet + README/Bruno links. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-08 / NEO-119 landed note; register stays **Planned** until E7M1-09+ (correct). |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-119 note appended. |
| `server/README.md` | **Matches** — route, envelope, side-effecting GET note, sample JSON. |
| Full-stack epic decomposition | **Matches** — server-only story; plan explicitly defers Godot to NEO-122; does not claim prototype slice complete. |
**Register / tracking:** E7.M1 row correctly remains **Planned** until accept HTTP (NEO-120) and client capstone (NEO-123); no further register edits required for this merge.
## Blocking issues
(none)
## Suggestions
1. ~~**Assert response array order explicitly** — `GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress` and the Bruno default test compare **sorted** quest ids. The plan and DTO doc promise catalog **`id`** ordinal order (same as **`GetDefinitionsInIdOrder()`**). For the fixed four-quest prototype, sorted vs sequence is equivalent today, but asserting sequence (as in `QuestDefinitionRegistryTests`) would lock the contract if ids ever stop sorting identically to registry order.~~ **Done.** Test asserts `GetDefinitionsInIdOrder()` sequence; Bruno compares response order without sort.
2. ~~**Side-effecting GET test store read-back** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts HTTP `completed` and pre-GET `Active`, but does not re-read **`IPlayerQuestStateStore`** after GET. Adding a post-GET store assertion would strengthen coverage of the documented mutating read semantics (mirrors the `beforeGet` guard already in Arrange).~~ **Done.** Post-GET `TryGetProgress` asserts `Completed`, terminal step index, and `CompletedAt`.
## Nits
- Nit: **`MapRow`** throws **`InvalidOperationException`** on unknown **`QuestProgressStatus`** (would surface as 500). Acceptable defensive guard; optional direct **`BuildSnapshot`** unit test if parity with **`EncounterProgressApiTests`** corrupt-state tests is desired later.
- Nit: **`QuestProgressApiTests`** helper block (~200 lines) duplicates patterns from **`QuestObjectiveWiringTests`** / encounter quest helpers — acceptable for integration isolation; consider shared test fixture only if NEO-120 adds more HTTP quest tests.
## Verification
```bash
# Primary signal for this story
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~QuestProgressApiTests"
# Quest module regression
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~NeonSprawl.Server.Tests.Game.Quests"
# Full server suite (CI)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
**Bruno:** run `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru` against a local server with fresh `dev-local-1`.
**Reviewer note:** All 7 `QuestProgressApiTests` and 85 quest-namespace tests passed locally during review.
**Manual:** None required beyond Bruno for this server-only story; Godot HUD verification belongs to NEO-122.

View File

@ -0,0 +1,361 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public sealed class QuestProgressApiTests
{
private const string PlayerId = "dev-local-1";
private const string AlphaNodeId = "prototype_resource_node_alpha";
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 GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
[Fact]
public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/%20/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var registry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
var expectedQuestOrder = registry.GetDefinitionsInIdOrder().Select(static d => d.Id).ToArray();
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
Assert.Equal(QuestProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal(PlayerId, body.PlayerId);
Assert.Equal(expectedQuestOrder.Length, body.Quests.Count);
Assert.Equal(expectedQuestOrder, body.Quests.Select(static row => row.QuestId).ToArray());
foreach (var row in body.Quests)
{
Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status);
Assert.Equal(0, row.CurrentStepIndex);
Assert.Empty(row.ObjectiveCounters);
Assert.Null(row.CompletedAt);
}
}
[Fact]
public async Task GetQuestProgress_ShouldReturnActivePartialCounters_WhenGatherIntroInProgress()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
Assert.True(TryAccept(deps, GatherQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(
PlayerId,
GatherQuestId,
GatherObjectiveId,
2,
out _));
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId);
Assert.Equal(QuestProgressApi.StatusActive, row.Status);
Assert.Equal(0, row.CurrentStepIndex);
Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]);
Assert.Null(row.CompletedAt);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnCompletedGatherIntro_WhenObjectivesSatisfied()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
Assert.True(TryAccept(deps, GatherQuestId).Success);
for (var i = 0; i < 3; i++)
{
Assert.True(TryGather(deps, AlphaNodeId).Success);
}
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId);
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(0, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
}
[Fact]
public async Task GetQuestProgress_ShouldReturnCompletedChainAtTerminalStepIndex_WhenOperatorChainFinished()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
CompleteOperatorChain(deps);
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId);
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(3, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
}
[Fact]
public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
var client = factory.CreateClient();
AcceptAndMarkComplete(deps, GatherQuestId);
AcceptAndMarkComplete(deps, RefineQuestId);
AcceptAndMarkComplete(deps, CombatQuestId);
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 1, out _));
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 2, out _));
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 3, out _));
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var beforeGet));
Assert.Equal(QuestProgressStatus.Active, beforeGet.Status);
Assert.Equal(3, beforeGet.CurrentStepIndex);
// Act
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId);
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(3, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterGet));
Assert.Equal(QuestProgressStatus.Completed, afterGet.Status);
Assert.Equal(3, afterGet.CurrentStepIndex);
Assert.NotNull(afterGet.CompletedAt);
}
private static void CompleteOperatorChain(QuestWiringTestDependencies deps)
{
AcceptAndMarkComplete(deps, GatherQuestId);
AcceptAndMarkComplete(deps, RefineQuestId);
CompleteCombatEncounter(deps);
Assert.True(TryAccept(deps, ChainQuestId).Success);
Assert.True(TryGather(deps, "prototype_urban_bulk_delta").Success);
SeedStack(deps, "scrap_metal_bulk", 5);
Assert.True(TryCraft(deps, "refine_scrap_standard").Success);
SeedStack(deps, "refined_plate_stock", 2);
SeedStack(deps, "scrap_metal_bulk", 1);
Assert.True(TryCraft(deps, "make_field_stim_mk0").Success);
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
Assert.Equal(3, progress.CurrentStepIndex);
}
private static void CompleteCombatEncounter(QuestWiringTestDependencies deps)
{
Assert.True(TryAccept(deps, CombatQuestId).Success);
DefeatAllRequiredTargets(deps);
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
"prototype_combat_pocket",
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.EncounterProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider);
Assert.True(result.Success);
}
private static void DefeatAllRequiredTargets(QuestWiringTestDependencies deps)
{
foreach (var npcId in new[] { "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" })
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.EncounterProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
npcId,
deps.EncounterRegistry,
deps.EncounterProgressStore,
deps.CompletionStore);
}
}
private static QuestStateOperationResult TryAccept(QuestWiringTestDependencies deps, string questId) =>
QuestStateOperations.TryAccept(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.InventoryStore,
deps.ItemRegistry,
deps.TimeProvider);
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
{
Assert.True(TryAccept(deps, questId).Success);
Assert.True(QuestStateOperations.TryMarkComplete(
PlayerId,
questId,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider).Success);
}
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
GatherOperations.TryGather(
PlayerId,
nodeId,
deps.NodeRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.InstanceStore,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider);
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
CraftOperations.TryCraft(
PlayerId,
recipeId,
quantity: 1,
deps.RecipeRegistry,
deps.ItemRegistry,
deps.InventoryStore,
deps.SkillRegistry,
deps.XpStore,
deps.LevelCurve,
deps.PerkUnlockEngine,
deps.QuestRegistry,
deps.ProgressStore,
deps.TimeProvider);
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
{
var outcome = PlayerInventoryOperations.TryAddStack(
PlayerId,
itemId,
quantity,
deps.ItemRegistry,
deps.InventoryStore);
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
}
private static QuestWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new QuestWiringTestDependencies(
services.GetRequiredService<IQuestDefinitionRegistry>(),
services.GetRequiredService<IPlayerQuestStateStore>(),
services.GetRequiredService<IPlayerInventoryStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IResourceNodeDefinitionRegistry>(),
services.GetRequiredService<IResourceNodeInstanceStore>(),
services.GetRequiredService<IRecipeDefinitionRegistry>(),
services.GetRequiredService<ISkillDefinitionRegistry>(),
services.GetRequiredService<IPlayerSkillProgressionStore>(),
services.GetRequiredService<ISkillLevelCurve>(),
services.GetRequiredService<PerkUnlockEngine>(),
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
TimeProvider.System);
}
private readonly record struct QuestWiringTestDependencies(
IQuestDefinitionRegistry QuestRegistry,
IPlayerQuestStateStore ProgressStore,
IPlayerInventoryStore InventoryStore,
IItemDefinitionRegistry ItemRegistry,
IResourceNodeDefinitionRegistry NodeRegistry,
IResourceNodeInstanceStore InstanceStore,
IRecipeDefinitionRegistry RecipeRegistry,
ISkillDefinitionRegistry SkillRegistry,
IPlayerSkillProgressionStore XpStore,
ISkillLevelCurve LevelCurve,
PerkUnlockEngine PerkUnlockEngine,
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore EncounterProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
TimeProvider TimeProvider);
}

View File

@ -199,6 +199,42 @@ public static class QuestObjectiveWiring
}
}
/// <summary>
/// GET polling hook (NEO-119): refresh <see cref="QuestObjectiveKinds.InventoryHasItem"/> counters and
/// auto-advance/complete active quests before a progress snapshot is returned.
/// </summary>
public static void TryRefreshInventoryProgressForPlayer(
string playerId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore,
IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry,
TimeProvider timeProvider,
ILogger? logger = null)
{
try
{
RefreshInventoryHasItemCountersForPlayer(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry);
TryCompleteAllActiveQuests(
playerId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider);
}
catch (Exception ex)
{
logger?.LogDebug(ex, "Quest progress GET refresh skipped for player {PlayerId}", playerId);
}
}
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> on the active step for one quest.</summary>
public static void TryProcessInventoryHasItemForQuest(
string playerId,

View File

@ -0,0 +1,99 @@
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Maps <c>GET /game/players/{{id}}/quest-progress</c> (NEO-119).</summary>
public static class QuestProgressApi
{
public const string StatusNotStarted = "not_started";
public const string StatusActive = "active";
public const string StatusCompleted = "completed";
public static WebApplication MapQuestProgressApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/quest-progress",
(string id, IPositionStateStore positions, IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore, IPlayerInventoryStore inventoryStore,
IItemDefinitionRegistry itemRegistry, TimeProvider timeProvider, ILogger<Program>? logger) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer(
trimmedId,
questRegistry,
progressStore,
inventoryStore,
itemRegistry,
timeProvider,
logger);
return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore));
});
return app;
}
internal static QuestProgressListResponse BuildSnapshot(
string playerId,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore progressStore)
{
var defs = questRegistry.GetDefinitionsInIdOrder();
var rows = new List<QuestProgressRowJson>(defs.Count);
foreach (var def in defs)
{
rows.Add(MapRow(playerId, def.Id, progressStore));
}
return new QuestProgressListResponse
{
PlayerId = playerId,
Quests = rows,
SchemaVersion = QuestProgressListResponse.CurrentSchemaVersion,
};
}
private static QuestProgressRowJson MapRow(
string playerId,
string questId,
IPlayerQuestStateStore progressStore)
{
if (!progressStore.TryGetProgress(playerId, questId, out var snapshot))
{
return new QuestProgressRowJson
{
QuestId = questId,
Status = StatusNotStarted,
CurrentStepIndex = 0,
ObjectiveCounters = new Dictionary<string, int>(StringComparer.Ordinal),
};
}
var counters = new Dictionary<string, int>(snapshot.ObjectiveCounters, StringComparer.Ordinal);
return snapshot.Status switch
{
QuestProgressStatus.Active => new QuestProgressRowJson
{
QuestId = questId,
Status = StatusActive,
CurrentStepIndex = snapshot.CurrentStepIndex,
ObjectiveCounters = counters,
},
QuestProgressStatus.Completed => new QuestProgressRowJson
{
QuestId = questId,
Status = StatusCompleted,
CurrentStepIndex = snapshot.CurrentStepIndex,
ObjectiveCounters = counters,
CompletedAt = snapshot.CompletedAt,
},
_ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."),
};
}
}

View File

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>JSON body for <c>GET /game/players/{{id}}/quest-progress</c> (NEO-119).</summary>
public sealed class QuestProgressListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Per-player rows ordered by catalog quest <c>id</c> (ordinal).</summary>
[JsonPropertyName("quests")]
public required IReadOnlyList<QuestProgressRowJson> Quests { get; init; }
}
/// <summary>Authoritative per-player progress for one quest definition.</summary>
public sealed class QuestProgressRowJson
{
[JsonPropertyName("questId")]
public required string QuestId { get; init; }
/// <summary><c>not_started</c>, <c>active</c>, or <c>completed</c>.</summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
[JsonPropertyName("currentStepIndex")]
public int CurrentStepIndex { get; init; }
/// <summary>Objective id → accumulated count for the current step only.</summary>
[JsonPropertyName("objectiveCounters")]
public required IReadOnlyDictionary<string, int> ObjectiveCounters { get; init; }
[JsonPropertyName("completedAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
}

View File

@ -77,6 +77,7 @@ app.MapPlayerCraftApi();
app.MapSkillProgressionSnapshotApi();
app.MapGigProgressionSnapshotApi();
app.MapEncounterProgressApi();
app.MapQuestProgressApi();
app.MapPerkStateApi();
if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") ||

View File

@ -158,7 +158,7 @@ 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. 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).
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 accept lands in E7M1-09 (NEO-120).
**Store interface methods:**
@ -171,7 +171,7 @@ Per-player quest runtime state lives in **`IPlayerQuestStateStore`**, keyed by *
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).
**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: `bruno/neon-sprawl-server/quest-progress/`.
### Quest state operations (NEO-117)
@ -206,7 +206,23 @@ Completed rows cannot regress to active without a reset API (none in prototype).
| **`EncounterCompletionOperations.TryCompleteAndGrant`** success | **`encounter_complete`** | Encounter id (counter → 1). |
| Inventory mutations above + **`QuestStateOperations.TryAccept`** / **`TryAdvanceStep`** | **`inventory_has_item`** | Bag snapshot read; counter set to `min(held, required)`. |
**`inventory_has_item`** is **not** re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). HTTP **`GET …/quest-progress`** is E7M1-08 ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)). Plan: [NEO-118 implementation plan](../../docs/plans/NEO-118-implementation-plan.md).
**`inventory_has_item`** is **not** re-evaluated for other objective kinds on accept/step entry (event-driven only for gather/craft/encounter). Plan: [NEO-118 implementation plan](../../docs/plans/NEO-118-implementation-plan.md).
### Per-player quest progress (NEO-119)
**`GET /game/players/{id}/quest-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`quests`**) backed by **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`**. Unknown or blank player ids return **404** (position gate, same as encounter-progress). Each row includes **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`** (objective id → count for the current step only), and optional **`completedAt`** when **`completed`**. Quests are ordered by catalog **`id`** (ordinal).
Before building the snapshot, the handler best-effort runs **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** — refreshes **`inventory_has_item`** counters and may auto-advance/complete active quests (side-effecting read for client HUD polling). Plan: [NEO-119 implementation plan](../../docs/plans/NEO-119-implementation-plan.md).
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/quest-progress"
```
Sample default response (no quests accepted):
```json
{"schemaVersion":1,"playerId":"dev-local-1","quests":[{"questId":"prototype_quest_combat_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_gather_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_operator_chain","status":"not_started","currentStepIndex":0,"objectiveCounters":{}},{"questId":"prototype_quest_refine_intro","status":"not_started","currentStepIndex":0,"objectiveCounters":{}}]}
```
## Encounter definitions (NEO-103)