Merge pull request #158 from ViPro-Technologies/NEO-119-get-quest-progress
NEO-119: GET /game/players/{id}/quest-progress
pull/159/head
commit
c388949b8d
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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** |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ app.MapPlayerCraftApi();
|
|||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapEncounterProgressApi();
|
||||
app.MapQuestProgressApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue