diff --git a/docs/plans/NEO-120-implementation-plan.md b/docs/plans/NEO-120-implementation-plan.md new file mode 100644 index 0000000..78fca44 --- /dev/null +++ b/docs/plans/NEO-120-implementation-plan.md @@ -0,0 +1,161 @@ +# NEO-120 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-120 | +| **Title** | E7M1-09: POST /game/players/{id}/quests/{questId}/accept | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-120/e7m1-09-post-gameplayersidquestsquestidaccept | +| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-09** | +| **Branch** | `NEO-120-quest-accept-post` | +| **Precursor** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations.TryAccept` (**landed on `main`**); [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — GET quest-progress (**landed on `main`**) | +| **Pattern** | [NEO-117](NEO-117-implementation-plan.md) — operations + reason codes; [NEO-119](NEO-119-implementation-plan.md) — quest progress row JSON; [NEO-55](NEO-55-implementation-plan.md) / `PlayerInventoryApi` — mutation envelope (`applied` + `reasonCode` + nested snapshot, HTTP **200** on deny) | +| **Blocks** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — client quest progress + accept HUD (E7M1-11) | +| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — accept binding + feedback label polls this POST then GET quest-progress. Bruno-only accept verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | + +## Kickoff clarifications + +**No clarifications needed.** [E7M1-09](E7M1-prototype-backlog.md#e7m1-09--post-gameplayersidquestsquestidaccept) backlog, Linear AC, landed [NEO-117](NEO-117-implementation-plan.md) operations, and mutation HTTP precedents (`PlayerInventoryApi`, `PlayerCraftApi`, `AbilityCastApi`) settle scope: + +- **Response envelope:** `accepted` + optional `reasonCode` + optional single **`quest`** row (`QuestProgressRowJson` shape from NEO-119) — backlog “updated progress row or deny payload”; inventory-style **200** on structured deny. +- **Request body:** optional v1 — omit body, empty body, or `{ "schemaVersion": 1 }` all valid; **400** only when body is present with wrong `schemaVersion`. +- **Player gate:** `IPositionStateStore` → **404** for unknown/blank ids (GET quest-progress / inventory precedent) before `TryAccept`. +- **Unknown quest:** **200** + `accepted: false` + `reasonCode: unknown_quest` (operations layer; not HTTP 404). +- **Deny with snapshot:** `already_active` / `already_completed` include current **`quest`** row from operations snapshot (no mutation on deny). +- **Manual QA doc:** skip `docs/manual-qa/NEO-120.md` — server-only; Bruno + API integration tests (NEO-119 precedent). + +## Goal, scope, and out-of-scope + +**Goal:** Expose player-initiated quest accept over HTTP so Godot (and Bruno) can transition a quest from **`not_started` → `active`** with structured deny feedback when prerequisites or state block accept. + +**In scope (from Linear + [E7M1-09](E7M1-prototype-backlog.md#e7m1-09--post-gameplayersidquestsquestidaccept)):** + +- **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi`; optional request body v1; response with **`accepted`**, optional **`reasonCode`**, optional **`quest`** progress row. +- Wire **`QuestStateOperations.TryAccept`** (inventory refresh + step completion on accept already in operations). +- Shared row projection with GET (`QuestProgressRowJson` from `QuestStepState` / missing row). +- Bruno `bruno/neon-sprawl-server/quest-progress/` — happy accept, prerequisite deny, duplicate accept. +- Integration tests (AAA): 404 player gate; happy accept; `prerequisite_incomplete`; `already_active`; `unknown_quest`. +- `server/README.md` accept route section. + +**Out of scope (from Linear + backlog):** + +- Abandon/reset POST. +- 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-120.md` (server-only per kickoff). + +## Acceptance criteria checklist + +- [ ] Successful accept transitions row **`not_started` → `active`**. +- [ ] Deny returns structured **`reasonCode`** without mutation. +- [ ] Bruno + integration tests pass in CI. + +## Technical approach + +1. **Route:** **`POST /game/players/{id}/quests/{questId}/accept`** — inject **`IPositionStateStore`**, **`IQuestDefinitionRegistry`**, **`IPlayerQuestStateStore`**, **`IPlayerInventoryStore`**, **`IItemDefinitionRegistry`**, **`TimeProvider`**. + +2. **Request:** `QuestAcceptRequest` with `schemaVersion` **1**. Handler accepts **`null`**, empty JSON, or `{ "schemaVersion": 1 }`. **400** when body is non-null and `schemaVersion` ≠ 1. + +3. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`QuestProgressApi`** GET). + +4. **Accept:** `QuestStateOperations.TryAccept(trimmedId, questId, …)` — `questId` from route (operations normalizes via registry). + +5. **Response mapping** (`QuestAcceptResponse` v1): + + | Outcome | HTTP | `accepted` | `reasonCode` | `quest` row | + |---------|------|------------|--------------|-------------| + | Success | 200 | `true` | omitted | active row from snapshot | + | Deny (no snapshot) | 200 | `false` | operations code | omitted | + | Deny (with snapshot) | 200 | `false` | operations code | row from snapshot (`already_active`, `already_completed`) | + + Map `QuestStepState` → `QuestProgressRowJson` via shared helper extracted from **`QuestProgressApi.MapRow`** (e.g. **`MapQuestProgressRow`**) so GET and POST stay aligned. + +6. **Register:** `app.MapQuestAcceptApi()` in **`Program.cs`** immediately after **`MapQuestProgressApi()`**. + +7. **Bruno** (`bruno/neon-sprawl-server/quest-progress/`): + + - **`Accept gather intro.bru`** — POST `dev-local-1` / `prototype_quest_gather_intro` → 200, `accepted: true`, `quest.status === active`, `currentStepIndex === 0`. + - **`Accept refine prerequisite deny.bru`** — POST refine intro without gather complete → 200, `accepted: false`, `reasonCode === prerequisite_incomplete`, no active refine row on subsequent GET. + - **`Accept duplicate.bru`** — accept gather twice → second 200, `accepted: false`, `reasonCode === already_active`, `quest.status === active`. + +8. **Docs:** Add **`server/README.md`** POST accept section (curl, response shape, reason codes). Update E7.M1 module snapshot / alignment register / E7M1 backlog on story land. + +### Expected v1 success response (prototype) + +```json +{ + "schemaVersion": 1, + "accepted": true, + "quest": { + "questId": "prototype_quest_gather_intro", + "status": "active", + "currentStepIndex": 0, + "objectiveCounters": {} + } +} +``` + +### Expected v1 deny response (prerequisite) + +```json +{ + "schemaVersion": 1, + "accepted": false, + "reasonCode": "prerequisite_incomplete" +} +``` + +### Accept flow + +```mermaid +flowchart TD + R[POST accept] --> B{Body schema ok?} + B -->|no| B400[400] + B -->|yes| G{Known player?} + G -->|no| N404[404] + G -->|yes| A[QuestStateOperations.TryAccept] + A --> M[Map QuestAcceptResponse] + M --> J[JSON 200] +``` + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | `Map*` extension; player gate; `TryAccept`; response mapping. | +| `server/NeonSprawl.Server/Game/Quests/QuestAcceptDtos.cs` | Optional request + v1 accept response DTOs. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | AAA HTTP integration: 404, happy accept, prerequisite deny, duplicate accept, unknown quest. | +| `bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru` | Bruno happy-path accept. | +| `bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru` | Bruno prerequisite deny. | +| `bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru` | Bruno duplicate accept deny. | +| `docs/plans/NEO-120-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Extract shared `MapQuestProgressRow` (or equivalent) for GET + accept POST row projection. | +| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestAcceptApi()` after `MapQuestProgressApi()`. | +| `server/README.md` | Document POST accept route, request/response shape, reason codes; replace E7M1-09 deferral stub. | +| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | HTTP quest accept bullet (on story complete). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-120 HTTP (on story complete). | +| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-120 (on story complete). | +| `docs/plans/E7M1-prototype-backlog.md` | E7M1-09 acceptance checkboxes (on story complete). | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `QuestAcceptApiTests.cs` | **404:** unknown player + whitespace id. **Happy accept:** POST gather intro → `accepted: true`, `quest.status` active, step 0, empty counters; GET confirms active. **Prerequisite deny:** POST refine intro without gather complete → `prerequisite_incomplete`, GET still `not_started` for refine. **Duplicate accept:** second POST → `already_active` with `quest` snapshot, no counter drift. **Unknown quest:** bogus quest id → `unknown_quest`. **Optional body:** POST with no body succeeds for happy accept. | +| Existing quest test suite | Regression smoke — operations + GET unchanged when accept not called. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Response field `accepted` vs `applied`** | Use **`accepted`** — matches `AbilityCastResponse` / perk `selected` mutation naming; backlog does not mandate `applied` | **adopted** | +| **Shared row mapper location** | Extract from `QuestProgressApi` to internal static helper in same file or small shared type — avoid duplicating status/counter mapping | **adopted** | +| **Accept may auto-complete first step** | Operations already run inventory wiring + step completion on accept (NEO-118); tests assert post-accept row as returned, not forced step 0 if wiring advances | **adopted** | +| **Bruno collection ordering** | Add accept `.bru` files beside existing quest-progress folder; no new collection root | **adopted** |