From 2b06eb2a1eff37877eaa2293eee3937a115b4e89 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:37:35 -0400 Subject: [PATCH 1/5] NEO-129: add implementation plan for quest-progress completionRewardSummary. --- docs/plans/NEO-129-implementation-plan.md | 178 ++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/plans/NEO-129-implementation-plan.md 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** | From 78e805e428c858d00e3911eba37e17dd5d8f5efc Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:38:37 -0400 Subject: [PATCH 2/5] NEO-129: add completionRewardSummary to quest-progress GET. Project IRewardDeliveryStore grant snapshots onto completed quest rows; extend tests, Bruno gather-intro spine, and docs. --- ...t progress after gather intro complete.bru | 75 +++++++++++++++++++ .../Get quest progress default.bru | 1 + .../modules/E7_M2_RewardAndUnlockRouter.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E7M2-prototype-backlog.md | 4 +- docs/plans/NEO-129-implementation-plan.md | 13 +++- .../Game/Quests/QuestProgressApiTests.cs | 46 ++++++++++++ .../Game/Quests/QuestAcceptApi.cs | 9 ++- .../Game/Quests/QuestProgressApi.cs | 69 +++++++++++++++-- .../Game/Quests/QuestProgressListDtos.cs | 35 +++++++++ server/README.md | 9 ++- 11 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru new file mode 100644 index 0000000..de5f823 --- /dev/null +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru @@ -0,0 +1,75 @@ +meta { + name: GET quest progress after gather intro complete + type: http + seq: 8 +} + +docs { + NEO-129: accept gather intro, complete via three alpha gathers, GET quest-progress asserts completionRewardSummary. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const headers = { "Content-Type": "application/json" }; + + await axios.post( + `${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`, + { schemaVersion: 1 }, + { headers, validateStatus: () => true }, + ); + + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { + schemaVersion: 1, + target: { x: 10, y: 0.9, z: -6 }, + }, + { headers }, + ); + + for (let i = 0; i < 3; i += 1) { + const interact = await axios.post( + `${baseUrl}/game/players/${playerId}/interact`, + { + schemaVersion: 1, + interactableId: "prototype_resource_node_alpha", + }, + { headers, validateStatus: () => true }, + ); + if (interact.status !== 200 || !interact.data?.success) { + throw new Error(`expected gather interact to succeed, got ${JSON.stringify(interact.data)}`); + } + } +} + +get { + url: {{baseUrl}}/game/players/{{playerId}}/quest-progress + body: none + auth: none +} + +tests { + test("gather intro row is completed with completionRewardSummary", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + const row = body.quests.find((x) => x.questId === "prototype_quest_gather_intro"); + expect(row).to.be.an("object"); + expect(row.status).to.equal("completed"); + expect(row.completionRewardSummary).to.be.an("object"); + expect(row.completionRewardSummary.itemGrants).to.eql([]); + expect(row.completionRewardSummary.skillXpGrants).to.eql([ + { skillId: "salvage", amount: 25 }, + ]); + }); + + test("active and not_started rows omit completionRewardSummary", function () { + const body = res.getBody(); + for (const row of body.quests) { + if (row.status === "not_started" || row.status === "active") { + expect(row.completionRewardSummary).to.equal(undefined); + } + } + }); +} diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru index 164412e..1f17c1d 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru @@ -39,6 +39,7 @@ tests { expect(row.currentStepIndex).to.equal(0); expect(row.objectiveCounters).to.eql({}); expect(row.completedAt).to.equal(undefined); + expect(row.completionRewardSummary).to.equal(undefined); } }); } diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index 6702f63..20a35cf 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -72,4 +72,6 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **NEO-127 (E7M2-04 router apply):** **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/` applies **`QuestRewardBundleRow`** item grants via **`PlayerInventoryOperations`** and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** (`mission_reward` only), with compensating item rollback on partial failure, then **`IRewardDeliveryStore.TryRecord`**. See [NEO-127 implementation plan](../../plans/NEO-127-implementation-plan.md); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). -**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. HTTP **`completionRewardSummary`** in NEO-129. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). +**NEO-128 (E7M2-05 quest-state wiring):** **`QuestStateOperations.TryMarkComplete`** calls **`RewardRouterOperations.TryDeliverQuestCompletion`** on the first-time completion path (deliver-then-mark); idempotent **`completed`** replay skips the router. **`QuestObjectiveWiring`** threads reward dependencies through gather/craft/encounter/GET refresh into the same path. See [NEO-128 implementation plan](../../plans/NEO-128-implementation-plan.md); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). + +**NEO-129 (E7M2-06 HTTP read):** **`GET /game/players/{id}/quest-progress`** projects optional **`completionRewardSummary`** on **`completed`** rows from **`IRewardDeliveryStore.TryGet`** — nested **`itemGrants`** + **`skillXpGrants`** mirroring delivery event snapshots; omitted when no delivery record. **`QuestAcceptApi`** embed rows use the same mapper. See [NEO-129 implementation plan](../../plans/NEO-129-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/Get quest progress after gather intro complete.bru`. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 1de2cdb..e0bd8e7 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory 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/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | --- diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index 9669b85..23b82b7 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -218,8 +218,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **Acceptance criteria** -- [ ] Completed quest rows include summary when delivery recorded. -- [ ] `not_started` / `active` rows omit summary. +- [x] Completed quest rows include summary when delivery recorded. +- [x] `not_started` / `active` rows omit summary. **Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131). diff --git a/docs/plans/NEO-129-implementation-plan.md b/docs/plans/NEO-129-implementation-plan.md index c96d8e9..c4fc5c1 100644 --- a/docs/plans/NEO-129-implementation-plan.md +++ b/docs/plans/NEO-129-implementation-plan.md @@ -49,8 +49,17 @@ ## Acceptance criteria checklist -- [ ] Completed quest rows include **`completionRewardSummary`** when delivery recorded. -- [ ] **`not_started`** / **`active`** rows omit summary. +- [x] Completed quest rows include **`completionRewardSummary`** when delivery recorded. +- [x] **`not_started`** / **`active`** rows omit summary. + +## Implementation reconciliation (shipped) + +- **DTOs:** **`QuestCompletionRewardSummaryJson`** with nested **`itemGrants`** + **`skillXpGrants`**; optional **`completionRewardSummary`** on **`QuestProgressRowJson`**. +- **Projection:** **`QuestProgressApi.BuildSnapshot`** / **`MapQuestProgressRow`** read **`IRewardDeliveryStore.TryGet`**; omit summary when no delivery event (kickoff). +- **Accept:** **`QuestAcceptApi.MapResponse`** threads **`playerId`** + **`deliveryStore`** into row mapper. +- **Tests:** eight AAA tests in **`QuestProgressApiTests`** — omit on default/active; gather complete salvage **25**; operator chain item + XP; idempotent GET replay. +- **Bruno:** **`Get quest progress after gather intro complete.bru`**; default GET asserts summary omitted on **`not_started`**. +- **Docs:** **`server/README.md`**; E7.M2 module anchor; alignment register; E7M2-06 backlog AC. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index af0396d..bd9cebd 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -79,6 +79,7 @@ public sealed class QuestProgressApiTests Assert.Equal(0, row.CurrentStepIndex); Assert.Empty(row.ObjectiveCounters); Assert.Null(row.CompletedAt); + Assert.Null(row.CompletionRewardSummary); } } @@ -109,6 +110,7 @@ public sealed class QuestProgressApiTests Assert.Equal(0, row.CurrentStepIndex); Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]); Assert.Null(row.CompletedAt); + Assert.Null(row.CompletionRewardSummary); } [Fact] @@ -135,6 +137,43 @@ public sealed class QuestProgressApiTests Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); Assert.Equal(0, row.CurrentStepIndex); Assert.NotNull(row.CompletedAt); + Assert.NotNull(row.CompletionRewardSummary); + Assert.Empty(row.CompletionRewardSummary!.ItemGrants); + Assert.Single(row.CompletionRewardSummary.SkillXpGrants); + Assert.Equal("salvage", row.CompletionRewardSummary.SkillXpGrants[0].SkillId); + Assert.Equal(25, row.CompletionRewardSummary.SkillXpGrants[0].Amount); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice() + { + // 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 firstResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + var secondResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + var firstBody = await firstResponse.Content.ReadFromJsonAsync(); + var secondBody = await secondResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(firstBody); + Assert.NotNull(secondBody); + var firstRow = Assert.Single(firstBody!.Quests, static r => r.QuestId == GatherQuestId); + var secondRow = Assert.Single(secondBody!.Quests, static r => r.QuestId == GatherQuestId); + Assert.NotNull(firstRow.CompletionRewardSummary); + Assert.NotNull(secondRow.CompletionRewardSummary); + Assert.Equal(firstRow.CompletionRewardSummary!.SkillXpGrants[0].SkillId, secondRow.CompletionRewardSummary!.SkillXpGrants[0].SkillId); + Assert.Equal(firstRow.CompletionRewardSummary.SkillXpGrants[0].Amount, secondRow.CompletionRewardSummary.SkillXpGrants[0].Amount); } [Fact] @@ -157,6 +196,13 @@ public sealed class QuestProgressApiTests Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); Assert.Equal(3, row.CurrentStepIndex); Assert.NotNull(row.CompletedAt); + Assert.NotNull(row.CompletionRewardSummary); + Assert.Single(row.CompletionRewardSummary!.ItemGrants); + Assert.Equal("survey_drone_kit", row.CompletionRewardSummary.ItemGrants[0].ItemId); + Assert.Equal(1, row.CompletionRewardSummary.ItemGrants[0].Quantity); + Assert.Single(row.CompletionRewardSummary.SkillXpGrants); + Assert.Equal("salvage", row.CompletionRewardSummary.SkillXpGrants[0].SkillId); + Assert.Equal(50, row.CompletionRewardSummary.SkillXpGrants[0].Amount); } [Fact] diff --git a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs index 8c6c0b7..90efda5 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs @@ -48,18 +48,21 @@ public static class QuestAcceptApi deliveryStore, timeProvider); - return Results.Json(MapResponse(result)); + return Results.Json(MapResponse(trimmedId, deliveryStore, result)); }); return app; } - internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result) + internal static QuestAcceptResponse MapResponse( + string playerId, + IRewardDeliveryStore deliveryStore, + QuestStateOperationResult result) { QuestProgressRowJson? quest = null; if (result.Snapshot is { } snapshot) { - quest = QuestProgressApi.MapQuestProgressRow(snapshot); + quest = QuestProgressApi.MapQuestProgressRow(playerId, snapshot, deliveryStore); } return new QuestAcceptResponse diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs index c91f20f..a385fe8 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs @@ -45,7 +45,7 @@ public static class QuestProgressApi timeProvider, logger); - return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore)); + return Results.Json(BuildSnapshot(trimmedId, questRegistry, progressStore, deliveryStore)); }); return app; @@ -54,13 +54,14 @@ public static class QuestProgressApi internal static QuestProgressListResponse BuildSnapshot( string playerId, IQuestDefinitionRegistry questRegistry, - IPlayerQuestStateStore progressStore) + IPlayerQuestStateStore progressStore, + IRewardDeliveryStore deliveryStore) { var defs = questRegistry.GetDefinitionsInIdOrder(); var rows = new List(defs.Count); foreach (var def in defs) { - rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore)); + rows.Add(MapQuestProgressRow(playerId, def.Id, progressStore, deliveryStore)); } return new QuestProgressListResponse @@ -74,17 +75,21 @@ public static class QuestProgressApi internal static QuestProgressRowJson MapQuestProgressRow( string playerId, string questId, - IPlayerQuestStateStore progressStore) + IPlayerQuestStateStore progressStore, + IRewardDeliveryStore deliveryStore) { if (!progressStore.TryGetProgress(playerId, questId, out var snapshot)) { return NotStartedRow(questId); } - return MapQuestProgressRow(snapshot); + return MapQuestProgressRow(playerId, snapshot, deliveryStore); } - internal static QuestProgressRowJson MapQuestProgressRow(QuestStepState snapshot) + internal static QuestProgressRowJson MapQuestProgressRow( + string playerId, + QuestStepState snapshot, + IRewardDeliveryStore deliveryStore) { var counters = new Dictionary(snapshot.ObjectiveCounters, StringComparer.Ordinal); return snapshot.Status switch @@ -103,11 +108,63 @@ public static class QuestProgressApi CurrentStepIndex = snapshot.CurrentStepIndex, ObjectiveCounters = counters, CompletedAt = snapshot.CompletedAt, + CompletionRewardSummary = MapCompletionRewardSummary(playerId, snapshot.QuestId, deliveryStore), }, _ => throw new InvalidOperationException($"Unknown quest progress status '{snapshot.Status}'."), }; } + 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), + }; + } + + /// Preserves commit-time grant order from . + private static List MapItemGrants(IReadOnlyList grants) + { + var summary = new List(grants.Count); + foreach (var grant in grants) + { + summary.Add( + new QuestItemGrantJson + { + ItemId = grant.ItemId, + Quantity = grant.Quantity, + }); + } + + return summary; + } + + /// Preserves commit-time grant order from . + private static List MapSkillXpGrants(IReadOnlyList grants) + { + var summary = new List(grants.Count); + foreach (var grant in grants) + { + summary.Add( + new QuestSkillXpGrantJson + { + SkillId = grant.SkillId, + Amount = grant.Amount, + }); + } + + return summary; + } + private static QuestProgressRowJson NotStartedRow(string questId) => new() { diff --git a/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs index a0f8803..7dc0439 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs @@ -38,4 +38,39 @@ public sealed class QuestProgressRowJson [JsonPropertyName("completedAt")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public DateTimeOffset? CompletedAt { get; init; } + + /// Grant snapshot when is completed and delivery was recorded (NEO-129). + [JsonPropertyName("completionRewardSummary")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; } +} + +/// Item + skill XP grants applied on first-time quest completion (NEO-129). +public sealed class QuestCompletionRewardSummaryJson +{ + [JsonPropertyName("itemGrants")] + public required IReadOnlyList ItemGrants { get; init; } + + [JsonPropertyName("skillXpGrants")] + public required IReadOnlyList SkillXpGrants { get; init; } +} + +/// One item grant line in . +public sealed class QuestItemGrantJson +{ + [JsonPropertyName("itemId")] + public required string ItemId { get; init; } + + [JsonPropertyName("quantity")] + public required int Quantity { get; init; } +} + +/// One skill XP grant line in . +public sealed class QuestSkillXpGrantJson +{ + [JsonPropertyName("skillId")] + public required string SkillId { get; init; } + + [JsonPropertyName("amount")] + public required int Amount { get; init; } } diff --git a/server/README.md b/server/README.md index 802942a..f2cdf49 100644 --- a/server/README.md +++ b/server/README.md @@ -212,7 +212,14 @@ Completed rows cannot regress to active without a reset API (none in prototype). ### 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). +**`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), optional **`completedAt`** when **`completed`**, and optional **`completionRewardSummary`** when **`completed`** and a delivery event exists in **`IRewardDeliveryStore`**. Quests are ordered by catalog **`id`** (ordinal). + +| Field | When present | +|-------|----------------| +| **`completedAt`** | **`status: completed`** | +| **`completionRewardSummary`** | **`status: completed`** and **`IRewardDeliveryStore.TryGet`** hits — maps **`GrantedItems`** → **`itemGrants`** (`itemId`, `quantity`) and **`GrantedSkillXp`** → **`skillXpGrants`** (`skillId`, `amount`). Omitted when not completed or when no delivery record exists. | + +Prototype: complete **`prototype_quest_gather_intro`** via objective wiring → row **`completed`** with **`completionRewardSummary.skillXpGrants: [{ skillId: salvage, amount: 25 }]`** and empty **`itemGrants`**. Plan: [NEO-129 implementation plan](../../docs/plans/NEO-129-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. 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). From 185b38b2428c94bc4a998ce00100cffd49e5b85b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:40:05 -0400 Subject: [PATCH 3/5] NEO-129: add code review for quest-progress completionRewardSummary. --- docs/reviews/2026-06-07-NEO-129.md | 55 ++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/reviews/2026-06-07-NEO-129.md diff --git a/docs/reviews/2026-06-07-NEO-129.md b/docs/reviews/2026-06-07-NEO-129.md new file mode 100644 index 0000000..d86d7cb --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-129.md @@ -0,0 +1,55 @@ +# Code review — NEO-129 (E7M2-06) + +**Date:** 2026-06-07 +**Scope:** Branch `NEO-129-quest-progress-completion-reward-summary` vs `main` — commits `2b06eb2` … `78e805e` +**Base:** `main` + +## Verdict + +**Approve with nits** — additive `completionRewardSummary` on completed quest-progress rows, sourced from `IRewardDeliveryStore`, matches the adopted plan and NEO-108 encounter precedent; mapper threading through `QuestAcceptApi` is correct; tests and Bruno cover primary AC paths; risk is low (server-only until NEO-131 client parse). + +## Summary + +NEO-129 extends **`GET /game/players/{id}/quest-progress`** with optional **`completionRewardSummary`** on **`completed`** rows: nested **`itemGrants`** + **`skillXpGrants`** mapped from **`RewardDeliveryEvent`** snapshots via **`IRewardDeliveryStore.TryGet`**, omitted for **`not_started`** / **`active`** and when no delivery record exists (kickoff). **`QuestProgressApi.BuildSnapshot`** / **`MapQuestProgressRow`** accept **`playerId`** + **`deliveryStore`**; **`QuestAcceptApi.MapResponse`** uses the same mapper so embedded accept rows stay consistent. DTOs in **`QuestProgressListDtos.cs`** use **`JsonIgnore(WhenWritingNull)`** and preserve commit-time grant order (XML on mappers mirrors NEO-108). Eight AAA integration tests in **`QuestProgressApiTests`** assert omit on default/active, gather-intro salvage **25**, operator-chain item + XP, and idempotent GET replay; Bruno adds gather-intro complete spine plus default GET omit assertion. Docs (plan reconciliation, E7.M2 anchor, alignment register, E7M2-06 backlog AC, **`server/README.md`**) are updated. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-129-implementation-plan.md` | **Matches** — kickoff nested shape and omit-on-miss adopted; reconciliation accurate; AC checked. | +| `docs/plans/E7M2-prototype-backlog.md` (E7M2-06) | **Matches** — AC checkboxes complete; client counterpart NEO-131 correctly deferred. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-129 HTTP read anchor added; separated from NEO-128 wiring note. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-06 HTTP read; row correctly stays **Planned** until NEO-132 capstone. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required. | +| `server/README.md` | **Matches** — quest-progress section documents **`completionRewardSummary`** projection source and gather-intro sample. | +| Full-stack epic decomposition | **N/A** — E7M2-06 is server HTTP read; client counterpart [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) explicitly out of scope. | + +**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate. + +## Blocking issues + +None. + +## Suggestions + +1. **GET-refresh chain completion summary** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts **`completed`** via inventory refresh wiring but does not assert **`CompletionRewardSummary`** (unlike gather and operator-chain complete tests). That path exercises deliver-then-mark; adding item + skill XP asserts would lock summary projection on the GET side-effect completion path. + +2. **Bruno idempotent second GET** — The implementation plan Bruno AC calls for a second GET unchanged; **`Get quest progress after gather intro complete.bru`** performs only one GET. Optional follow-up: duplicate GET in pre-request or a second request file to match plan and integration test coverage. + +3. **Completed without delivery record** — Kickoff adopts omit-on-miss (softer than NEO-108 fail-fast). No test forces **`status: completed`** with a store miss (e.g. direct **`IPlayerQuestStateStore`** seed without **`IRewardDeliveryStore`** record). Low priority given NEO-128 deliver-then-mark invariant in normal paths; a single defensive test would document the contract. + +## Nits + +- Nit: Idempotent replay test (`GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice`) compares only the first **`skillXpGrants`** line, not full **`completionRewardSummary`** object equality (Bruno uses **`eql`** on arrays). Fine for single-line gather intro; operator-chain idempotent replay is untested. + +- Nit: **`QuestAcceptApiTests`** do not assert **`CompletionRewardSummary`** null on active accept embedded rows. Mapper consistency is covered indirectly via shared **`MapQuestProgressRow`**; a one-line assert on existing accept-success tests would mirror GET omit coverage. + +- Nit: Grant-order XML is present on **`MapItemGrants`** / **`MapSkillXpGrants`**; C# operator-chain test asserts fields via **`Single`** / index **0** (order-agnostic for single grants). Acceptable for prototype freeze table; Bruno gather test asserts ordered **`skillXpGrants`** array. + +## Verification + +```bash +cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~QuestProgressApiTests" +``` + +Optional: run Bruno folder `bruno/neon-sprawl-server/quest-progress/` against a running dev server (especially **`Get quest progress after gather intro complete.bru`**). From 9ab3a707961b85fd9bcab39a22285f8afafb442a Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 18:48:45 -0400 Subject: [PATCH 4/5] NEO-129: address review test and Bruno coverage gaps. --- ...t progress after gather intro complete.bru | 29 ++++- docs/plans/NEO-129-implementation-plan.md | 2 +- docs/reviews/2026-06-07-NEO-129.md | 13 +- .../Game/Quests/QuestAcceptApiTests.cs | 6 + .../Game/Quests/QuestProgressApiTests.cs | 112 +++++++++++++++--- 5 files changed, 138 insertions(+), 24 deletions(-) diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru index de5f823..d7c5cf6 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru @@ -5,7 +5,7 @@ meta { } docs { - NEO-129: accept gather intro, complete via three alpha gathers, GET quest-progress asserts completionRewardSummary. + NEO-129: accept gather intro, complete via three alpha gathers, GET quest-progress asserts completionRewardSummary; second GET unchanged. } script:pre-request { @@ -42,6 +42,26 @@ script:pre-request { throw new Error(`expected gather interact to succeed, got ${JSON.stringify(interact.data)}`); } } + + const firstGet = await axios.get( + `${baseUrl}/game/players/${playerId}/quest-progress`, + { validateStatus: () => true }, + ); + if (firstGet.status !== 200) { + throw new Error(`expected first GET quest-progress to return 200, got ${firstGet.status}`); + } + const firstRow = firstGet.data.quests.find( + (x) => x.questId === "prototype_quest_gather_intro", + ); + if (!firstRow || firstRow.status !== "completed" || !firstRow.completionRewardSummary) { + throw new Error( + `expected first GET gather intro completed with summary, got ${JSON.stringify(firstRow)}`, + ); + } + bru.setVar( + "firstGatherCompletionRewardSummary", + JSON.stringify(firstRow.completionRewardSummary), + ); } get { @@ -64,6 +84,13 @@ tests { ]); }); + test("second GET completionRewardSummary matches first GET", function () { + const body = res.getBody(); + const row = body.quests.find((x) => x.questId === "prototype_quest_gather_intro"); + const firstSummary = JSON.parse(bru.getVar("firstGatherCompletionRewardSummary")); + expect(row.completionRewardSummary).to.eql(firstSummary); + }); + test("active and not_started rows omit completionRewardSummary", function () { const body = res.getBody(); for (const row of body.quests) { diff --git a/docs/plans/NEO-129-implementation-plan.md b/docs/plans/NEO-129-implementation-plan.md index c4fc5c1..4ef3663 100644 --- a/docs/plans/NEO-129-implementation-plan.md +++ b/docs/plans/NEO-129-implementation-plan.md @@ -57,7 +57,7 @@ - **DTOs:** **`QuestCompletionRewardSummaryJson`** with nested **`itemGrants`** + **`skillXpGrants`**; optional **`completionRewardSummary`** on **`QuestProgressRowJson`**. - **Projection:** **`QuestProgressApi.BuildSnapshot`** / **`MapQuestProgressRow`** read **`IRewardDeliveryStore.TryGet`**; omit summary when no delivery event (kickoff). - **Accept:** **`QuestAcceptApi.MapResponse`** threads **`playerId`** + **`deliveryStore`** into row mapper. -- **Tests:** eight AAA tests in **`QuestProgressApiTests`** — omit on default/active; gather complete salvage **25**; operator chain item + XP; idempotent GET replay. +- **Tests:** eleven AAA tests in **`QuestProgressApiTests`** — omit on default/active; gather complete salvage **25**; operator chain item + XP; GET-refresh chain terminal summary; omit when completed without delivery record; full-summary idempotent GET replay (gather + operator chain). **`QuestAcceptApiTests`** — null summary on active accept; summary on already-completed deny row. - **Bruno:** **`Get quest progress after gather intro complete.bru`**; default GET asserts summary omitted on **`not_started`**. - **Docs:** **`server/README.md`**; E7.M2 module anchor; alignment register; E7M2-06 backlog AC. diff --git a/docs/reviews/2026-06-07-NEO-129.md b/docs/reviews/2026-06-07-NEO-129.md index d86d7cb..e9c9754 100644 --- a/docs/reviews/2026-06-07-NEO-129.md +++ b/docs/reviews/2026-06-07-NEO-129.md @@ -2,7 +2,8 @@ **Date:** 2026-06-07 **Scope:** Branch `NEO-129-quest-progress-completion-reward-summary` vs `main` — commits `2b06eb2` … `78e805e` -**Base:** `main` +**Base:** `main` +**Follow-up:** Review suggestions and nits addressed in a subsequent commit on the same branch. ## Verdict @@ -32,17 +33,17 @@ None. ## Suggestions -1. **GET-refresh chain completion summary** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts **`completed`** via inventory refresh wiring but does not assert **`CompletionRewardSummary`** (unlike gather and operator-chain complete tests). That path exercises deliver-then-mark; adding item + skill XP asserts would lock summary projection on the GET side-effect completion path. +1. ~~**GET-refresh chain completion summary** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts **`completed`** via inventory refresh wiring but does not assert **`CompletionRewardSummary`** (unlike gather and operator-chain complete tests). That path exercises deliver-then-mark; adding item + skill XP asserts would lock summary projection on the GET side-effect completion path.~~ **Done.** `AssertOperatorChainCompletionRewardSummary` on GET-refresh chain terminal test. -2. **Bruno idempotent second GET** — The implementation plan Bruno AC calls for a second GET unchanged; **`Get quest progress after gather intro complete.bru`** performs only one GET. Optional follow-up: duplicate GET in pre-request or a second request file to match plan and integration test coverage. +2. ~~**Bruno idempotent second GET** — The implementation plan Bruno AC calls for a second GET unchanged; **`Get quest progress after gather intro complete.bru`** performs only one GET. Optional follow-up: duplicate GET in pre-request or a second request file to match plan and integration test coverage.~~ **Done.** Pre-request first GET + `bru.setVar`; main GET test asserts `eql` with stored summary. -3. **Completed without delivery record** — Kickoff adopts omit-on-miss (softer than NEO-108 fail-fast). No test forces **`status: completed`** with a store miss (e.g. direct **`IPlayerQuestStateStore`** seed without **`IRewardDeliveryStore`** record). Low priority given NEO-128 deliver-then-mark invariant in normal paths; a single defensive test would document the contract. +3. ~~**Completed without delivery record** — Kickoff adopts omit-on-miss (softer than NEO-108 fail-fast). No test forces **`status: completed`** with a store miss (e.g. direct **`IPlayerQuestStateStore`** seed without **`IRewardDeliveryStore`** record). Low priority given NEO-128 deliver-then-mark invariant in normal paths; a single defensive test would document the contract.~~ **Done.** `GetQuestProgress_ShouldOmitCompletionRewardSummary_WhenCompletedWithoutDeliveryRecord`. ## Nits -- Nit: Idempotent replay test (`GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice`) compares only the first **`skillXpGrants`** line, not full **`completionRewardSummary`** object equality (Bruno uses **`eql`** on arrays). Fine for single-line gather intro; operator-chain idempotent replay is untested. +- ~~Nit: Idempotent replay test (`GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenGatherIntroCompletedTwice`) compares only the first **`skillXpGrants`** line, not full **`completionRewardSummary`** object equality (Bruno uses **`eql`** on arrays). Fine for single-line gather intro; operator-chain idempotent replay is untested.~~ **Done.** `AssertCompletionRewardSummariesEqual` helper; added operator-chain idempotent replay test. -- Nit: **`QuestAcceptApiTests`** do not assert **`CompletionRewardSummary`** null on active accept embedded rows. Mapper consistency is covered indirectly via shared **`MapQuestProgressRow`**; a one-line assert on existing accept-success tests would mirror GET omit coverage. +- ~~Nit: **`QuestAcceptApiTests`** do not assert **`CompletionRewardSummary`** null on active accept embedded rows. Mapper consistency is covered indirectly via shared **`MapQuestProgressRow`**; a one-line assert on existing accept-success tests would mirror GET omit coverage.~~ **Done.** Null on active accept; summary on already-completed deny row. - Nit: Grant-order XML is present on **`MapItemGrants`** / **`MapSkillXpGrants`**; C# operator-chain test asserts fields via **`Single`** / index **0** (order-agnostic for single grants). Acceptable for prototype freeze table; Bruno gather test asserts ordered **`skillXpGrants`** array. diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs index d31af38..5df5ff9 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs @@ -76,6 +76,7 @@ public sealed class QuestAcceptApiTests Assert.Equal(0, body.Quest.CurrentStepIndex); Assert.Empty(body.Quest.ObjectiveCounters); Assert.Null(body.Quest.CompletedAt); + Assert.Null(body.Quest.CompletionRewardSummary); var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); @@ -261,6 +262,11 @@ public sealed class QuestAcceptApiTests Assert.Equal(QuestProgressApi.StatusCompleted, body.Quest!.Status); Assert.Equal(0, body.Quest.CurrentStepIndex); Assert.NotNull(body.Quest.CompletedAt); + Assert.NotNull(body.Quest.CompletionRewardSummary); + Assert.Empty(body.Quest.CompletionRewardSummary!.ItemGrants); + Assert.Single(body.Quest.CompletionRewardSummary.SkillXpGrants); + Assert.Equal("salvage", body.Quest.CompletionRewardSummary.SkillXpGrants[0].SkillId); + Assert.Equal(25, body.Quest.CompletionRewardSummary.SkillXpGrants[0].Amount); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs index bd9cebd..eceb0fd 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs @@ -137,11 +137,7 @@ public sealed class QuestProgressApiTests Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); Assert.Equal(0, row.CurrentStepIndex); Assert.NotNull(row.CompletedAt); - Assert.NotNull(row.CompletionRewardSummary); - Assert.Empty(row.CompletionRewardSummary!.ItemGrants); - Assert.Single(row.CompletionRewardSummary.SkillXpGrants); - Assert.Equal("salvage", row.CompletionRewardSummary.SkillXpGrants[0].SkillId); - Assert.Equal(25, row.CompletionRewardSummary.SkillXpGrants[0].Amount); + AssertGatherIntroCompletionRewardSummary(row.CompletionRewardSummary); } [Fact] @@ -170,10 +166,58 @@ public sealed class QuestProgressApiTests Assert.NotNull(secondBody); var firstRow = Assert.Single(firstBody!.Quests, static r => r.QuestId == GatherQuestId); var secondRow = Assert.Single(secondBody!.Quests, static r => r.QuestId == GatherQuestId); - Assert.NotNull(firstRow.CompletionRewardSummary); - Assert.NotNull(secondRow.CompletionRewardSummary); - Assert.Equal(firstRow.CompletionRewardSummary!.SkillXpGrants[0].SkillId, secondRow.CompletionRewardSummary!.SkillXpGrants[0].SkillId); - Assert.Equal(firstRow.CompletionRewardSummary.SkillXpGrants[0].Amount, secondRow.CompletionRewardSummary.SkillXpGrants[0].Amount); + AssertGatherIntroCompletionRewardSummary(firstRow.CompletionRewardSummary); + AssertCompletionRewardSummariesEqual(firstRow.CompletionRewardSummary, secondRow.CompletionRewardSummary); + } + + [Fact] + public async Task GetQuestProgress_ShouldReturnSameCompletionRewardSummary_WhenOperatorChainCompletedTwice() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var deps = ResolveDependencies(factory); + var client = factory.CreateClient(); + CompleteOperatorChain(deps); + + // Act + var firstResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + var secondResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress"); + + // Assert + Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode); + var firstBody = await firstResponse.Content.ReadFromJsonAsync(); + var secondBody = await secondResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(firstBody); + Assert.NotNull(secondBody); + var firstRow = Assert.Single(firstBody!.Quests, static r => r.QuestId == ChainQuestId); + var secondRow = Assert.Single(secondBody!.Quests, static r => r.QuestId == ChainQuestId); + AssertOperatorChainCompletionRewardSummary(firstRow.CompletionRewardSummary); + AssertCompletionRewardSummariesEqual(firstRow.CompletionRewardSummary, secondRow.CompletionRewardSummary); + } + + [Fact] + public async Task GetQuestProgress_ShouldOmitCompletionRewardSummary_WhenCompletedWithoutDeliveryRecord() + { + // 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.TryMarkComplete(PlayerId, GatherQuestId, DateTimeOffset.UtcNow, out _)); + Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, 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(); + Assert.NotNull(body); + var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId); + Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); + Assert.NotNull(row.CompletedAt); + Assert.Null(row.CompletionRewardSummary); } [Fact] @@ -196,13 +240,7 @@ public sealed class QuestProgressApiTests Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); Assert.Equal(3, row.CurrentStepIndex); Assert.NotNull(row.CompletedAt); - Assert.NotNull(row.CompletionRewardSummary); - Assert.Single(row.CompletionRewardSummary!.ItemGrants); - Assert.Equal("survey_drone_kit", row.CompletionRewardSummary.ItemGrants[0].ItemId); - Assert.Equal(1, row.CompletionRewardSummary.ItemGrants[0].Quantity); - Assert.Single(row.CompletionRewardSummary.SkillXpGrants); - Assert.Equal("salvage", row.CompletionRewardSummary.SkillXpGrants[0].SkillId); - Assert.Equal(50, row.CompletionRewardSummary.SkillXpGrants[0].Amount); + AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary); } [Fact] @@ -238,12 +276,54 @@ public sealed class QuestProgressApiTests Assert.Equal(QuestProgressApi.StatusCompleted, row.Status); Assert.Equal(3, row.CurrentStepIndex); Assert.NotNull(row.CompletedAt); + AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary); 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 AssertGatherIntroCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) + { + Assert.NotNull(summary); + Assert.Empty(summary!.ItemGrants); + Assert.Single(summary.SkillXpGrants); + Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId); + Assert.Equal(25, summary.SkillXpGrants[0].Amount); + } + + private static void AssertOperatorChainCompletionRewardSummary(QuestCompletionRewardSummaryJson? summary) + { + Assert.NotNull(summary); + Assert.Single(summary!.ItemGrants); + Assert.Equal("survey_drone_kit", summary.ItemGrants[0].ItemId); + Assert.Equal(1, summary.ItemGrants[0].Quantity); + Assert.Single(summary.SkillXpGrants); + Assert.Equal("salvage", summary.SkillXpGrants[0].SkillId); + Assert.Equal(50, summary.SkillXpGrants[0].Amount); + } + + private static void AssertCompletionRewardSummariesEqual( + QuestCompletionRewardSummaryJson? first, + QuestCompletionRewardSummaryJson? second) + { + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(first!.ItemGrants.Count, second!.ItemGrants.Count); + for (var i = 0; i < first.ItemGrants.Count; i++) + { + Assert.Equal(first.ItemGrants[i].ItemId, second.ItemGrants[i].ItemId); + Assert.Equal(first.ItemGrants[i].Quantity, second.ItemGrants[i].Quantity); + } + + Assert.Equal(first.SkillXpGrants.Count, second.SkillXpGrants.Count); + for (var i = 0; i < first.SkillXpGrants.Count; i++) + { + Assert.Equal(first.SkillXpGrants[i].SkillId, second.SkillXpGrants[i].SkillId); + Assert.Equal(first.SkillXpGrants[i].Amount, second.SkillXpGrants[i].Amount); + } + } + private static void CompleteOperatorChain(QuestWiringTestDependencies deps) { AcceptAndMarkComplete(deps, GatherQuestId); From 5126e82c4a283d072eb9494c8d8cf9b81b957d41 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 21:16:21 -0400 Subject: [PATCH 5/5] NEO-129: fix Bruno gather interact check (allowed not success). Tolerate craft-spine partial progress and node_depleted when the quest is already completed in the shared CI collection run. --- ...t progress after gather intro complete.bru | 93 ++++++++++++------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru index d7c5cf6..ac37ad7 100644 --- a/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru +++ b/bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru @@ -5,7 +5,8 @@ meta { } docs { - NEO-129: accept gather intro, complete via three alpha gathers, GET quest-progress asserts completionRewardSummary; second GET unchanged. + NEO-129: accept gather intro, complete via alpha gathers, GET quest-progress asserts completionRewardSummary; second GET unchanged. + Tolerates craft spine (seq 5) partial progress and node_depleted on alpha when quest already completed. } script:pre-request { @@ -13,54 +14,84 @@ script:pre-request { const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const headers = { "Content-Type": "application/json" }; + const gatherQuestId = "prototype_quest_gather_intro"; + const alphaNodeId = "prototype_resource_node_alpha"; - await axios.post( - `${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`, - { schemaVersion: 1 }, - { headers, validateStatus: () => true }, - ); + function gatherRow(body) { + return body.quests.find((x) => x.questId === gatherQuestId); + } - await axios.post( - `${baseUrl}/game/players/${playerId}/move`, - { - schemaVersion: 1, - target: { x: 10, y: 0.9, z: -6 }, - }, - { headers }, - ); + async function getQuestProgress() { + const response = await axios.get( + `${baseUrl}/game/players/${playerId}/quest-progress`, + { validateStatus: () => true }, + ); + if (response.status !== 200) { + throw new Error(`quest-progress GET failed: ${response.status}`); + } + return response.data; + } - for (let i = 0; i < 3; i += 1) { + async function tryGatherAlpha() { const interact = await axios.post( `${baseUrl}/game/players/${playerId}/interact`, { schemaVersion: 1, - interactableId: "prototype_resource_node_alpha", + interactableId: alphaNodeId, }, { headers, validateStatus: () => true }, ); - if (interact.status !== 200 || !interact.data?.success) { - throw new Error(`expected gather interact to succeed, got ${JSON.stringify(interact.data)}`); + if (interact.status !== 200) { + throw new Error(`gather interact HTTP ${interact.status}: ${JSON.stringify(interact.data)}`); } + if (interact.data?.allowed === true) { + return; + } + if (interact.data?.reasonCode === "node_depleted") { + return; + } + throw new Error(`gather interact denied: ${JSON.stringify(interact.data)}`); } - const firstGet = await axios.get( - `${baseUrl}/game/players/${playerId}/quest-progress`, - { validateStatus: () => true }, + await axios.post( + `${baseUrl}/game/players/${playerId}/quests/${gatherQuestId}/accept`, + { schemaVersion: 1 }, + { headers, validateStatus: () => true }, ); - if (firstGet.status !== 200) { - throw new Error(`expected first GET quest-progress to return 200, got ${firstGet.status}`); - } - const firstRow = firstGet.data.quests.find( - (x) => x.questId === "prototype_quest_gather_intro", - ); - if (!firstRow || firstRow.status !== "completed" || !firstRow.completionRewardSummary) { - throw new Error( - `expected first GET gather intro completed with summary, got ${JSON.stringify(firstRow)}`, + + let progress = await getQuestProgress(); + let row = gatherRow(progress); + if (!row || row.status === "not_started") { + await axios.post( + `${baseUrl}/game/players/${playerId}/move`, + { + schemaVersion: 1, + target: { x: 10, y: 0.9, z: -6 }, + }, + { headers }, ); } + + for (let attempt = 0; attempt < 8; attempt += 1) { + progress = await getQuestProgress(); + row = gatherRow(progress); + if (row?.status === "completed" && row.completionRewardSummary) { + break; + } + await tryGatherAlpha(); + } + + progress = await getQuestProgress(); + row = gatherRow(progress); + if (!row || row.status !== "completed" || !row.completionRewardSummary) { + throw new Error( + `expected gather intro completed with summary, got ${JSON.stringify(row)}`, + ); + } + bru.setVar( "firstGatherCompletionRewardSummary", - JSON.stringify(firstRow.completionRewardSummary), + JSON.stringify(row.completionRewardSummary), ); }