Merge pull request #168 from ViPro-Technologies/NEO-129-quest-progress-completion-reward-summary

NEO-129: extend quest-progress GET with completionRewardSummary
pull/169/head
VinPropane 2026-06-07 22:02:19 -04:00 committed by GitHub
commit 9b2c209096
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 627 additions and 14 deletions

View File

@ -0,0 +1,133 @@
meta {
name: GET quest progress after gather intro complete
type: http
seq: 8
}
docs {
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 {
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" };
const gatherQuestId = "prototype_quest_gather_intro";
const alphaNodeId = "prototype_resource_node_alpha";
function gatherRow(body) {
return body.quests.find((x) => x.questId === gatherQuestId);
}
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;
}
async function tryGatherAlpha() {
const interact = await axios.post(
`${baseUrl}/game/players/${playerId}/interact`,
{
schemaVersion: 1,
interactableId: alphaNodeId,
},
{ headers, validateStatus: () => true },
);
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)}`);
}
await axios.post(
`${baseUrl}/game/players/${playerId}/quests/${gatherQuestId}/accept`,
{ schemaVersion: 1 },
{ headers, validateStatus: () => true },
);
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(row.completionRewardSummary),
);
}
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("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) {
if (row.status === "not_started" || row.status === "active") {
expect(row.completionRewardSummary).to.equal(undefined);
}
}
});
}

View File

@ -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);
}
});
}

View File

@ -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`.

File diff suppressed because one or more lines are too long

View File

@ -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).

View File

@ -0,0 +1,187 @@
# 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
- [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:** 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.
## 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** |

View File

@ -0,0 +1,56 @@
# 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`
**Follow-up:** Review suggestions and nits addressed in a subsequent commit on the same branch.
## 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.~~ **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.~~ **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.~~ **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.~~ **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.~~ **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.
## 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`**).

View File

@ -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]

View File

@ -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,87 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(0, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
AssertGatherIntroCompletionRewardSummary(row.CompletionRewardSummary);
}
[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<QuestProgressListResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
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);
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<QuestProgressListResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
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<QuestProgressListResponse>();
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]
@ -157,6 +240,7 @@ public sealed class QuestProgressApiTests
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
Assert.Equal(3, row.CurrentStepIndex);
Assert.NotNull(row.CompletedAt);
AssertOperatorChainCompletionRewardSummary(row.CompletionRewardSummary);
}
[Fact]
@ -192,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);

View File

@ -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

View File

@ -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<QuestProgressRowJson>(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<string, int>(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),
};
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedItems"/>.</summary>
private static List<QuestItemGrantJson> MapItemGrants(IReadOnlyList<RewardItemGrantApplied> grants)
{
var summary = new List<QuestItemGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new QuestItemGrantJson
{
ItemId = grant.ItemId,
Quantity = grant.Quantity,
});
}
return summary;
}
/// <summary>Preserves commit-time grant order from <see cref="RewardDeliveryEvent.GrantedSkillXp"/>.</summary>
private static List<QuestSkillXpGrantJson> MapSkillXpGrants(IReadOnlyList<RewardSkillXpGrantApplied> grants)
{
var summary = new List<QuestSkillXpGrantJson>(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()
{

View File

@ -38,4 +38,39 @@ public sealed class QuestProgressRowJson
[JsonPropertyName("completedAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>Grant snapshot when <see cref="Status"/> is <c>completed</c> and delivery was recorded (NEO-129).</summary>
[JsonPropertyName("completionRewardSummary")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public QuestCompletionRewardSummaryJson? CompletionRewardSummary { get; init; }
}
/// <summary>Item + skill XP grants applied on first-time quest completion (NEO-129).</summary>
public sealed class QuestCompletionRewardSummaryJson
{
[JsonPropertyName("itemGrants")]
public required IReadOnlyList<QuestItemGrantJson> ItemGrants { get; init; }
[JsonPropertyName("skillXpGrants")]
public required IReadOnlyList<QuestSkillXpGrantJson> SkillXpGrants { get; init; }
}
/// <summary>One item grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
public sealed class QuestItemGrantJson
{
[JsonPropertyName("itemId")]
public required string ItemId { get; init; }
[JsonPropertyName("quantity")]
public required int Quantity { get; init; }
}
/// <summary>One skill XP grant line in <see cref="QuestCompletionRewardSummaryJson"/>.</summary>
public sealed class QuestSkillXpGrantJson
{
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("amount")]
public required int Amount { get; init; }
}

View File

@ -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).