diff --git a/docs/plans/NEO-129-implementation-plan.md b/docs/plans/NEO-129-implementation-plan.md new file mode 100644 index 0000000..c96d8e9 --- /dev/null +++ b/docs/plans/NEO-129-implementation-plan.md @@ -0,0 +1,178 @@ +# NEO-129 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-129 | +| **Title** | E7M2-06: Extend GET quest-progress with completionRewardSummary | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-129/e7m2-06-extend-get-quest-progress-with-completionrewardsummary | +| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-06** | +| **Branch** | `NEO-129-quest-progress-completion-reward-summary` | +| **Precursor** | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) **`Done`** — deliver-then-mark wiring in `QuestStateOperations.TryMarkComplete` | +| **Pattern** | [NEO-108](NEO-108-implementation-plan.md) — optional grant summary on completed rows from event/delivery store; [NEO-119](NEO-119-implementation-plan.md) — quest-progress GET envelope | +| **Blocks** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — client quest completion reward HUD (E7M2-08) | +| **Client counterpart** | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — parse **`completionRewardSummary`** in Godot; **not in this story** | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **`completionRewardSummary` shape** | How should item + skill XP lines appear in JSON? | **Nested object** `{ itemGrants: [{ itemId, quantity }], skillXpGrants: [{ skillId, amount }] }` — mirrors content **`completionRewardBundle`** and **`RewardDeliveryEvent`** grant snapshots; NEO-131 needs both kinds. | **Adopted** — nested bundle shape | +| **Missing delivery event** | When **`status: completed`** but **`IRewardDeliveryStore.TryGet`** misses? | **Omit** **`completionRewardSummary`** — matches Linear AC (“when delivery recorded”); softer than NEO-108 encounter fail-fast. | **Adopted** — omit when no event | + +**Additional defaults (no kickoff question — settled by backlog / landed code):** + +- **Source of truth:** **`IRewardDeliveryStore.TryGet(playerId, questId)`** → map **`GrantedItems`** / **`GrantedSkillXp`** only; no second read from quest catalog bundle at HTTP layer. +- **`schemaVersion`:** stay **1** — additive optional field (NEO-108 precedent for **`rewardGrantSummary`**). +- **Row coverage:** **`not_started`** / **`active`** omit **`completionRewardSummary`**; empty grant arrays included when summary is present (e.g. gather intro: **`itemGrants: []`**, **`skillXpGrants: [{ skillId: salvage, amount: 25 }]`**). +- **Mapper consistency:** thread **`IRewardDeliveryStore`** through **`MapQuestProgressRow`** so **`QuestAcceptApi`** embed rows match GET when **`completed`**. +- **Manual QA doc:** skip **`docs/manual-qa/NEO-129.md`** — server-only; Bruno + API integration tests (NEO-108 / NEO-119 precedent). + +## Goal, scope, and out-of-scope + +**Goal:** Client-readable grant summary on completed quest rows over existing **`GET /game/players/{id}/quest-progress`**. + +**In scope (from Linear + [E7M2-06](E7M2-prototype-backlog.md#e7m2-06--extend-get-quest-progress-with-completionrewardsummary)):** + +- Extend **`QuestProgressApi`** DTOs + OpenAPI-stable JSON: per completed quest optional **`completionRewardSummary`** (nested **`itemGrants`** + **`skillXpGrants`**). +- Populate from **`IRewardDeliveryStore`** delivery event snapshot. +- Integration tests (AAA): omit on **`not_started`** / **`active`**; present on gather-intro complete + operator-chain complete; idempotent GET unchanged. +- Bruno assertion on gather-intro complete path. +- `server/README.md` quest-progress section update; E7.M2 module alignment anchor. + +**Out of scope (from Linear):** + +- Godot HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) E7M2-08). +- New top-level route; **`QuestStateOperationResult`** shape changes; telemetry hooks ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)). +- Postgres delivery-store persistence. + +## Acceptance criteria checklist + +- [ ] Completed quest rows include **`completionRewardSummary`** when delivery recorded. +- [ ] **`not_started`** / **`active`** rows omit summary. + +## Technical approach + +### 1. Wire DTOs (`QuestProgressListDtos.cs`) + +Add grant line types and summary wrapper (camelCase JSON, stable with content schemas): + +| Type | JSON fields | +|------|-------------| +| **`QuestItemGrantJson`** | **`itemId`**, **`quantity`** | +| **`QuestSkillXpGrantJson`** | **`skillId`**, **`amount`** | +| **`QuestCompletionRewardSummaryJson`** | **`itemGrants`**, **`skillXpGrants`** | + +Add optional **`completionRewardSummary`** on **`QuestProgressRowJson`** with **`JsonIgnore(WhenWritingNull)`**. + +Reuse item line shape from **`EncounterRewardGrantJson`** field names; skill lines match **`quest-skill-xp-grant.schema.json`**. + +### 2. Extend projection (`QuestProgressApi.cs`) + +- **`BuildSnapshot`**: accept **`IRewardDeliveryStore`**; pass into per-row mapper. +- **`MapQuestProgressRow(playerId, questId, progressStore, deliveryStore)`** and **`MapQuestProgressRow(playerId, snapshot, deliveryStore)`**: + - On **`completed`**: **`deliveryStore.TryGet`** → when hit, map grant snapshots preserving commit-time order (mirror **`EncounterProgressApi.MapGrantSummary`**). + - When miss: leave **`completionRewardSummary`** null (omitted in JSON). + - On **`active`** / **`not_started`**: no store lookup; summary omitted. + +```csharp +private static QuestCompletionRewardSummaryJson? MapCompletionRewardSummary( + string playerId, + string questId, + IRewardDeliveryStore deliveryStore) +{ + if (!deliveryStore.TryGet(playerId, questId, out var deliveryEvent)) + { + return null; + } + + return new QuestCompletionRewardSummaryJson + { + ItemGrants = MapItemGrants(deliveryEvent.GrantedItems), + SkillXpGrants = MapSkillXpGrants(deliveryEvent.GrantedSkillXp), + }; +} +``` + +### 3. Accept response alignment (`QuestAcceptApi.cs`) + +**`MapResponse`**: when embedding **`QuestProgressRowJson`**, call **`MapQuestProgressRow(result.PlayerId, snapshot, deliveryStore)`** — requires threading **`playerId`** + **`deliveryStore`** into **`MapResponse`** from the route handler (already injected). + +### 4. Prototype row expectations (frozen E7M2-01) + +| Quest id | **`completionRewardSummary`** when completed | +|----------|-----------------------------------------------| +| **`prototype_quest_gather_intro`** | **`itemGrants: []`**, **`skillXpGrants: [{ skillId: salvage, amount: 25 }]`** | +| **`prototype_quest_refine_intro`** | **`itemGrants: []`**, **`skillXpGrants: [{ skillId: refine, amount: 25 }]`** | +| **`prototype_quest_combat_intro`** | **`itemGrants: []`**, **`skillXpGrants: [{ skillId: salvage, amount: 25 }]`** | +| **`prototype_quest_operator_chain`** | **`itemGrants: [{ itemId: survey_drone_kit, quantity: 1 }]`**, **`skillXpGrants: [{ skillId: salvage, amount: 50 }]`** | + +### 5. Bruno (`bruno/neon-sprawl-server/quest-progress/`) + +**`Get quest progress after gather intro complete.bru`** (new): + +- Pre-request: accept gather intro (idempotent) → move near **`prototype_resource_node_alpha`** → interact/gather until objective completes (3× scrap on alpha, same spine as **`QuestProgressApiTests`**). +- GET **`/game/players/{{playerId}}/quest-progress`** → assert gather row **`status: completed`**, **`completionRewardSummary.skillXpGrants`** matches salvage **25**, **`itemGrants`** empty array; second GET unchanged. + +### 6. Docs + +- **`server/README.md`** — extend quest-progress table with **`completionRewardSummary`** projection source (**`IRewardDeliveryStore`**), sample JSON snippet for gather intro. +- **`E7_M2_RewardAndUnlockRouter.md`** — NEO-129 HTTP anchor. +- **`documentation_and_implementation_alignment.md`** — E7.M2 row note when landed. +- **`E7M2-prototype-backlog.md`** — check E7M2-06 AC when complete. + +### Read flow + +```mermaid +sequenceDiagram + participant Client + participant API as QuestProgressApi + participant Wiring as QuestObjectiveWiring + participant Store as IPlayerQuestStateStore + participant Delivery as IRewardDeliveryStore + + Client->>API: GET /game/players/{id}/quest-progress + API->>Wiring: TryRefreshInventoryProgressForPlayer + API->>Store: per catalog quest TryGetProgress + alt status completed + API->>Delivery: TryGet(playerId, questId) + Delivery-->>API: RewardDeliveryEvent or miss + end + API-->>Client: schemaVersion 1 + quests[] (+ optional completionRewardSummary) +``` + +## Files to add + +| Path | Purpose | +|------|---------| +| `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru` | Gather-intro complete spine → GET asserts **`completionRewardSummary`**. | +| `docs/plans/NEO-129-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Add grant line + **`completionRewardSummary`** wrapper types; optional field on row DTO. | +| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Thread **`IRewardDeliveryStore`** through **`BuildSnapshot`** / **`MapQuestProgressRow`**; map delivery event → summary. | +| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | Pass **`playerId`** + **`deliveryStore`** into row mapper for embedded quest rows. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | Assert summary omitted on default/active; present on gather complete + operator chain; idempotent replay. | +| `server/README.md` | Document **`completionRewardSummary`** on quest-progress GET. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | NEO-129 implementation anchor. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment register when landed. | +| `docs/plans/E7M2-prototype-backlog.md` | Mark E7M2-06 acceptance criteria when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `QuestProgressApiTests.cs` | **Omit:** existing **`not_started`** / **`active`** tests assert **`CompletionRewardSummary`** null. **Gather complete:** extend **`GetQuestProgress_ShouldReturnCompletedGatherIntro_WhenObjectivesSatisfied`** — **`skillXpGrants`** salvage **25**, empty **`itemGrants`**. **Operator chain:** extend chain complete test — **`survey_drone_kit` ×1** + salvage **50**. **Idempotent read:** second GET same summary (new test or assert in gather test). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Completed without delivery event** | Omit summary per kickoff; no throw. | **adopted** | +| **Empty `itemGrants` in JSON** | Include **`[]`** when summary present — explicit for client parsers (NEO-131). | **adopted** | +| **Bruno shared player state** | Pre-request idempotent accept + gather; tolerate prior collection seq (NEO-119 folder pattern). | **adopted** | +| **NEO-131 display copy** | Defer formatting / HUD timing to client story. | **deferred** |