Compare commits
76 Commits
749b83d0cc
...
4e5d92b6c8
| Author | SHA1 | Date |
|---|---|---|
|
|
4e5d92b6c8 | |
|
|
039672cdac | |
|
|
be9bb8f961 | |
|
|
71fbf3d628 | |
|
|
c764f5af6b | |
|
|
65ca11171b | |
|
|
581c90ab34 | |
|
|
5b84ffd75a | |
|
|
35b8904f15 | |
|
|
ce142b9271 | |
|
|
35fa4ad192 | |
|
|
3d8df939b6 | |
|
|
2a8fe31b14 | |
|
|
b20d9c18b5 | |
|
|
0372991d87 | |
|
|
766837d7e8 | |
|
|
ad80ed8760 | |
|
|
8d026bdca4 | |
|
|
3924695619 | |
|
|
47ddde671f | |
|
|
0ae6bea327 | |
|
|
d3c7dbb1ad | |
|
|
fdd1fe1536 | |
|
|
c8e7449756 | |
|
|
afcf0fba28 | |
|
|
98a9602bb0 | |
|
|
85ead01a0b | |
|
|
31609917a5 | |
|
|
f4e9f62533 | |
|
|
ec71fba260 | |
|
|
4cfec3ac7f | |
|
|
316966f447 | |
|
|
803d66ea29 | |
|
|
5e146cd934 | |
|
|
bada3024c2 | |
|
|
c663721d34 | |
|
|
91bdb18357 | |
|
|
bfded69b3b | |
|
|
46e24d35d6 | |
|
|
3606d0ba57 | |
|
|
2514772b43 | |
|
|
6397d81ca3 | |
|
|
2bf4d325e1 | |
|
|
b3f9b22ef2 | |
|
|
eba0d09d70 | |
|
|
ba75aeed79 | |
|
|
64da7611d9 | |
|
|
632a7472de | |
|
|
0b3ef92ab6 | |
|
|
f801692091 | |
|
|
d1fa08b8c4 | |
|
|
e041c02959 | |
|
|
b36fe6d863 | |
|
|
4e75ec6493 | |
|
|
e37671d815 | |
|
|
150cb73f9a | |
|
|
a3932752b8 | |
|
|
88d01778a1 | |
|
|
89affe2b32 | |
|
|
705a7cd028 | |
|
|
72b98a627c | |
|
|
6af9f42df8 | |
|
|
ea3b7cceb5 | |
|
|
14bdec367c | |
|
|
9fe6cb36fd | |
|
|
2fa2e35ca6 | |
|
|
b22b68ad1f | |
|
|
c388949b8d | |
|
|
ad906fec40 | |
|
|
1565d2cde7 | |
|
|
9e4c52f653 | |
|
|
066d1ff981 | |
|
|
78e5de3338 | |
|
|
cbd6706e41 | |
|
|
71dbdd9a3e | |
|
|
286a1a036f |
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks prototype_npc_melee, casts pulse four times (3.2s between), fifth request expects target_defeated deny.
|
||||
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks prototype_npc_melee, casts pulse four times (3.2s between), fifth request expects target_defeated deny. NEO-118: encounter completion grant path runs quest objective wiring (active encounter_complete quests).
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. No inventory POST shortcuts for spine materials.
|
||||
NEO-70 prototype spine: accumulate >=11 scrap_metal_bulk (gather when nodes allow, reuse persisted inventory after dotnet test), refine_scrap_efficient when refined_plate_stock < 2, then make_field_stim_mk0. NEO-118: best-effort quest objective wiring on craft success (active craft_recipe quests). No inventory POST shortcuts for spine materials.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
|
||||
NEO-63: successful interact on `resource_node` kind runs GatherOperations — inventory grant (scrap_metal_bulk), salvage XP (activity, 10), and depletion. NEO-118: best-effort quest objective wiring on gather success (active gather_item quests). Pre-request moves in range of alpha anchor (12, -6). Optional: GET inventory / skill-progression after this request.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
meta {
|
||||
name: Accept duplicate
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-120: second accept on an active quest returns already_active with quest snapshot (no mutation).
|
||||
Pre-request ensures gather intro is active (idempotent when seq 3 already ran).
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/quests/prototype_quest_gather_intro/accept`,
|
||||
{ schemaVersion: 1 },
|
||||
jsonHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_gather_intro/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("duplicate accept returns already_active with active quest row", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("already_active");
|
||||
expect(body.quest).to.be.an("object");
|
||||
expect(body.quest.questId).to.equal("prototype_quest_gather_intro");
|
||||
expect(body.quest.status).to.equal("active");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
meta {
|
||||
name: Accept gather intro
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-120: POST accept gather intro — not_started to active for dev-local-1.
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_gather_intro/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("accept returns 200 with accepted true and active quest row", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(true);
|
||||
expect(body.reasonCode).to.equal(undefined);
|
||||
expect(body.quest).to.be.an("object");
|
||||
expect(body.quest.questId).to.equal("prototype_quest_gather_intro");
|
||||
expect(body.quest.status).to.equal("active");
|
||||
expect(body.quest.currentStepIndex).to.equal(0);
|
||||
expect(body.quest.objectiveCounters).to.eql({});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
meta {
|
||||
name: Accept refine prerequisite deny
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-120: refine intro requires completed gather intro — deny prerequisite_incomplete without mutation.
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quests/prototype_quest_refine_intro/accept
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("denies accept with prerequisite_incomplete", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.accepted).to.equal(false);
|
||||
expect(body.reasonCode).to.equal("prerequisite_incomplete");
|
||||
expect(body.quest).to.equal(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
meta {
|
||||
name: GET quest progress default
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-119: fresh dev-local-1 row before any quest accept — all four catalog quests not_started.
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/{{playerId}}/quest-progress
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 JSON with schema v1", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
|
||||
expect(body.quests).to.be.an("array");
|
||||
expect(body.quests.length).to.equal(4);
|
||||
});
|
||||
|
||||
test("all prototype quests are not_started with empty counters", function () {
|
||||
const body = res.getBody();
|
||||
const expectedIds = [
|
||||
"prototype_quest_combat_intro",
|
||||
"prototype_quest_gather_intro",
|
||||
"prototype_quest_operator_chain",
|
||||
"prototype_quest_refine_intro",
|
||||
];
|
||||
const actualIds = body.quests.map((row) => row.questId);
|
||||
expect(actualIds).to.eql(expectedIds);
|
||||
for (const row of body.quests) {
|
||||
expect(row.status).to.equal("not_started");
|
||||
expect(row.currentStepIndex).to.equal(0);
|
||||
expect(row.objectiveCounters).to.eql({});
|
||||
expect(row.completedAt).to.equal(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (reward delivery store NEO-126)
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-126 registers IRewardDeliveryStore (in-memory prototype). No quest reward delivery HTTP API in this story — use this request to confirm the host started after store DI wiring.
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
@ -207,7 +207,7 @@ Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility
|
|||
|
||||
**Scripts:** `ability_cast_client.gd`, `combat_targets_client.gd`, `gig_progression_client.gd` — wired from `main.gd`.
|
||||
|
||||
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
|
||||
**Economy HUD:** **`GigXpLabel`** lives under **`EconomyHudSection/BodyScroll/Body/`** (hidden when **`Economy HUD`** collapsed, NEO-75). Gig GET refreshes on boot and when cast accept carries **`targetDefeated: true`** only — not on every hit.
|
||||
|
||||
**Preconditions:** **Server restart** before capstone run resets in-memory dummy HP and gig XP baseline.
|
||||
|
||||
|
|
@ -218,13 +218,53 @@ Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.m
|
|||
- **`GET /game/players/{id}/encounter-progress`** — server-authoritative per-encounter **`state`**, **`defeatedTargetIds`**, and **`rewardGrantSummary`** when **`completed`** (NEO-108); see [server README — Per-player encounter progress](../server/README.md#per-player-encounter-progress-neo-108).
|
||||
- **Scripts:** `scripts/encounter_progress_client.gd`; wired from `main.gd` in `_setup_encounter_progress_sync()`.
|
||||
- **HUD:**
|
||||
- **`UICanvas/HudRoot/EncounterProgressLabel`** — frozen pocket **`prototype_combat_pocket`**: **`not started (0/3)`** → **`{n}/3`** while **`active`** → **`completed (3/3)`**.
|
||||
- **`UICanvas/HudRoot/EncounterCompleteLabel`** — **`Loot: —`** until **`completed`**; then one line per grant (**`displayName`** via **`ItemDefinitionsClient`**, fallback **`itemId`**).
|
||||
- **`UICanvas/HudRootScroll/HudRoot/EncounterProgressLabel`** — frozen pocket **`prototype_combat_pocket`**: **`not started (0/3)`** → **`{n}/3`** while **`active`** → **`completed (3/3)`**.
|
||||
- **`UICanvas/HudRootScroll/HudRoot/EncounterCompleteLabel`** — **`Loot: —`** until **`completed`**; then one line per grant (**`displayName`** via **`ItemDefinitionsClient`**, fallback **`itemId`**).
|
||||
- **Refresh:** boot hydrate + **`GET`** after cast accept with **`targetDefeated: true`** (alongside gig/combat refreshes). No periodic poll or manual key.
|
||||
- **Inventory:** on **`completed`** row, **`InventoryLabel`** auto-refreshes (no **I**) so bag shows **`scrap_metal_bulk`** and **`contract_handoff_token`**.
|
||||
- **Inventory:** on **`completed`** row, **`InventoryLabel`** auto-refreshes (no **I**) so bag shows **`scrap_metal_bulk`** and **`contract_handoff_token`**; **`QuestProgressLabel`** refreshes too so **`inventory_has_item`** / combat-intro steps stay current if the defeat-triggered quest GET was skipped or raced.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.md).
|
||||
|
||||
## Quest progress + accept HUD (NEO-122)
|
||||
|
||||
- **`GET /game/players/{id}/quest-progress`** — server-authoritative per-quest **`status`**, **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`** (NEO-119); see [server README — Per-player quest progress](../server/README.md#per-player-quest-progress-neo-119).
|
||||
- **`POST /game/players/{id}/quests/{questId}/accept`** — optional v1 body; **`accepted`** + optional **`reasonCode`** + optional **`quest`** row (NEO-120).
|
||||
- **`GET /game/world/quest-definitions`** — cached on boot for display names and eligible-quest iteration (NEO-115).
|
||||
- **Scripts:** `scripts/quest_progress_client.gd`, `scripts/quest_definitions_client.gd`; wired from `main.gd` in `_setup_quest_progress_sync()`.
|
||||
- **HUD:**
|
||||
- **`UICanvas/HudRootScroll/HudRoot/QuestProgressLabel`** — all four prototype quests in catalog order with compact status/step/counter lines.
|
||||
- **`UICanvas/HudRootScroll/HudRoot/QuestAcceptFeedbackLabel`** — accept success/deny/failure copy.
|
||||
- **Accept keys:** **Q** = `prototype_quest_gather_intro`; **Shift+Q** = first catalog-order **`not_started`** quest with prerequisites **`completed`** in current snapshot.
|
||||
- **Refresh:** boot hydrate + GET after gather (interaction success), craft success, cast with **`targetDefeated: true`**, encounter progress GET when pocket **`completed`** (alongside inventory refresh), and after accept POST. No periodic poll.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-122.md`](../docs/manual-qa/NEO-122.md).
|
||||
|
||||
## End-to-end onboarding quest loop (NEO-123)
|
||||
|
||||
Epic 7 Slice 1 capstone — complete all four prototype quests (three onboarding intros + operator chain) **in Godot** without Bruno.
|
||||
|
||||
**Flow:** fresh server restart → **Q** gather intro → **Shift+Q** combat intro → **Shift+Q** refine intro → **Shift+Q** operator chain → four chain steps → Godot restart idempotency.
|
||||
|
||||
| Step | Input / trigger | HUD / server outcome |
|
||||
|------|-----------------|----------------------|
|
||||
| Boot | Godot **F5** + fresh server | Four quests **`not started`**; empty bag |
|
||||
| Gather intro | **Q** + **R** at nodes (×3 scrap) | Salvage Run **`active`** → **`completed`** |
|
||||
| Combat intro | **Shift+Q** + defeat ×3 NPCs | Clear the Pocket **`completed`**; encounter **`completed (3/3)`**; **`contract_handoff_token`** in bag |
|
||||
| Refine intro | **Shift+Q** + craft **`refine_scrap_standard`** | Refine Stock **`completed`** |
|
||||
| Operator chain | **Shift+Q** + chain objectives | Operator Chain **`active step 1…4`** → **`completed`** when token held |
|
||||
| Idempotency | Stop Godot + **F5** (server still running) | All four quests still **`completed`** |
|
||||
| Duplicate accept | **Q** / **Shift+Q** on finished quests | **`already_completed`** / **`no eligible quest`**; HUD unchanged |
|
||||
|
||||
**Accept order note:** **Shift+Q** uses quest-definitions **catalog order** (ordinal `id`). After gather intro completes, combat intro is offered before refine intro — see [`docs/manual-qa/NEO-123.md`](../docs/manual-qa/NEO-123.md).
|
||||
|
||||
**Cross-links:** [NEO-112](../docs/plans/NEO-112-implementation-plan.md) catalog · [NEO-118](../docs/plans/NEO-118-implementation-plan.md) objective wiring · [NEO-119](../docs/plans/NEO-119-implementation-plan.md) quest-progress GET · [NEO-120](../docs/plans/NEO-120-implementation-plan.md) accept POST · [NEO-122](../docs/manual-qa/NEO-122.md) HUD components · [NEO-75](../docs/manual-qa/NEO-75.md) economy craft · [NEO-111](../docs/manual-qa/NEO-111.md) combat token loot.
|
||||
|
||||
**Scripts:** `quest_progress_client.gd`, `quest_definitions_client.gd`, `quest_hud_controller.gd`, `encounter_progress_client.gd`, `inventory_client.gd`, `craft_client.gd`, `ability_cast_client.gd` — wired from `main.gd`.
|
||||
|
||||
**Preconditions:** **Server restart** before capstone run resets quest progress, inventory, encounter state, and resource nodes.
|
||||
|
||||
Full capstone checklist: [`docs/manual-qa/NEO-123.md`](../docs/manual-qa/NEO-123.md).
|
||||
|
||||
## End-to-end encounter clear loop (NEO-111)
|
||||
|
||||
Epic 5 Slice 3 capstone — defeat all three prototype NPCs, receive loot + quest token **once**, verify inventory **in Godot** without Bruno.
|
||||
|
|
@ -253,7 +293,7 @@ Full capstone checklist: [`docs/manual-qa/NEO-111.md`](../docs/manual-qa/NEO-111
|
|||
- **`GET /game/players/{id}/inventory`** — server-authoritative bag (**24** slots) + equipment stub (**1** slot); see [server README — Player inventory](../server/README.md#player-inventory-neo-54-store-neo-55-http).
|
||||
- **`GET /game/world/item-definitions`** — cached on boot for **`displayName`** labels (NEO-53).
|
||||
- **Scripts:** `scripts/inventory_client.gd`, `scripts/item_definitions_client.gd`; wired from `main.gd` on boot and on **`inventory_refresh`** (**I**).
|
||||
- **HUD:** `UICanvas/EconomyHudSection/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block.
|
||||
- **HUD:** `UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel` lists **non-empty bag** rows and **equipment slot 0** (shows **`— empty`** when unoccupied). Failed GET paints **`error — …`** on the label and **`push_warning`** in Output. **`Economy HUD`** checkbox (NEO-75) collapses the economy block.
|
||||
- **Refresh:** press **I** (or call **`InventoryClient.request_sync_from_server()`** from `main.gd` helpers — entrypoint for NEO-73 gather/craft refresh).
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
|
||||
|
|
@ -261,9 +301,9 @@ Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md).
|
|||
## Gather feedback on interact (NEO-73)
|
||||
|
||||
- **`POST /game/players/{id}/interact`** on **`resource_node`** kinds — server gather engine (NEO-63); no separate gather HTTP on client.
|
||||
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
|
||||
- **`GET /game/players/{id}/skill-progression`** — **`salvage`** row on **`UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel`**; boot hydrate + refresh after successful gather.
|
||||
- **Scripts:** `scripts/skill_progression_client.gd`, `scripts/prototype_interactable_picker.gd`; **`interaction_request_client.gd`** emits **`interaction_result_received`**; wired from `main.gd`.
|
||||
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**).
|
||||
- **HUD:** `UICanvas/GatherFeedbackLabel` — success delta **`+N {displayName}`** or deny **`Gather: denied — {reasonCode}`** (including **`node_depleted`**). **`UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel`** refreshes automatically on successful gather (no **I**).
|
||||
- **R binding:** nearest in-range **`resource_node`** by horizontal distance to catalog anchors (same radius rule as glow preview).
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
|
||||
|
|
@ -273,7 +313,7 @@ Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md).
|
|||
- **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel inside **`EconomyHudSection`** (below **`SkillProgressionLabel`**).
|
||||
- **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md).
|
||||
- **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`.
|
||||
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/Body/InventoryLabel`** refreshes on craft success only (no **I**).
|
||||
- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`UICanvas/EconomyHudSection/BodyScroll/Body/SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`UICanvas/EconomyHudSection/BodyScroll/Body/InventoryLabel`** refreshes on craft success only (no **I**).
|
||||
- **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available.
|
||||
|
||||
Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.md).
|
||||
|
|
@ -293,7 +333,7 @@ Capstone proof that Epic 3 prototype gather→craft works **in Godot** without B
|
|||
|
||||
**Material minimum:** **11** scrap from one **R** at each of four prototype nodes (+1, +2, +3, +5) → two **`refine_scrap_standard`** → one **`make_field_stim_mk0`**.
|
||||
|
||||
**HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`).
|
||||
**HUD paths:** feedback labels stay on **`UICanvas`**; economy block under **`UICanvas/EconomyHudSection/BodyScroll/Body/`** (`InventoryLabel`, `SkillProgressionLabel`, `CraftRecipePanel`).
|
||||
|
||||
Full capstone checklist: [`docs/manual-qa/NEO-75.md`](../docs/manual-qa/NEO-75.md).
|
||||
|
||||
|
|
@ -355,7 +395,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
|
|||
|
||||
**CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that.
|
||||
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
|
||||
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`quest_progress_client.gd`**, **`quest_definitions_client.gd`** (NEO-122), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_recipe_panel_test.gd`** (NEO-122 follow-up), **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
|
||||
|
||||
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
[ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"]
|
||||
[ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"]
|
||||
[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"]
|
||||
[ext_resource type="Script" uid="uid://bneo122qstprog01" path="res://scripts/quest_progress_client.gd" id="25_quest_prog"]
|
||||
[ext_resource type="Script" uid="uid://bneo122qstdefs01" path="res://scripts/quest_definitions_client.gd" id="26_quest_defs"]
|
||||
[ext_resource type="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"]
|
||||
[ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
|
||||
|
||||
|
|
@ -1118,6 +1120,12 @@ script = ExtResource("21_gig_prog")
|
|||
[node name="EncounterProgressClient" type="Node" parent="." unique_id=2500012]
|
||||
script = ExtResource("24_enc_prog")
|
||||
|
||||
[node name="QuestProgressClient" type="Node" parent="." unique_id=2500013]
|
||||
script = ExtResource("25_quest_prog")
|
||||
|
||||
[node name="QuestDefinitionsClient" type="Node" parent="." unique_id=2500014]
|
||||
script = ExtResource("26_quest_defs")
|
||||
|
||||
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
|
|
@ -1142,13 +1150,20 @@ theme_override_constants/outline_size = 8
|
|||
theme_override_font_sizes/font_size = 22
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="HudRoot" type="VBoxContainer" parent="UICanvas" unique_id=9000022]
|
||||
[node name="HudRootScroll" type="ScrollContainer" parent="UICanvas" unique_id=9000029]
|
||||
offset_left = 8.0
|
||||
offset_top = 8.0
|
||||
offset_right = 664.0
|
||||
offset_bottom = 392.0
|
||||
horizontal_scroll_mode = 0
|
||||
vertical_scroll_mode = 1
|
||||
|
||||
[node name="HudRoot" type="VBoxContainer" parent="UICanvas/HudRootScroll" unique_id=9000022]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="PlayerPositionLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000003]
|
||||
[node name="PlayerPositionLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000003]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.92, 0.95, 0.98, 1)
|
||||
|
|
@ -1161,7 +1176,7 @@ y: —
|
|||
z: —
|
||||
srv: —"
|
||||
|
||||
[node name="TargetLockLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000004]
|
||||
[node name="TargetLockLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000004]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.86, 0.94, 1, 1)
|
||||
|
|
@ -1173,7 +1188,7 @@ text = "Target: —
|
|||
Validity: —
|
||||
Seq: —"
|
||||
|
||||
[node name="CastFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000005]
|
||||
[node name="CastFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000005]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.95, 0.88, 0.72, 1)
|
||||
|
|
@ -1183,7 +1198,7 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "Cast: —"
|
||||
|
||||
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000018]
|
||||
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000018]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.92, 0.78, 0.82, 1)
|
||||
|
|
@ -1193,7 +1208,7 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "Target HP: — (Tab → dummy)"
|
||||
|
||||
[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000019]
|
||||
[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000019]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.82, 0.92, 0.98, 1)
|
||||
|
|
@ -1203,7 +1218,7 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "Player HP: —"
|
||||
|
||||
[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000023]
|
||||
[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000023]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.9, 0.82, 0.72, 1)
|
||||
|
|
@ -1214,7 +1229,7 @@ autowrap_mode = 3
|
|||
text = "Encounter:
|
||||
Loading…"
|
||||
|
||||
[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000024]
|
||||
[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000024]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.95, 0.9, 0.65, 1)
|
||||
|
|
@ -1224,7 +1239,28 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "Loot: —"
|
||||
|
||||
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020]
|
||||
[node name="QuestProgressLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000025]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.82, 0.92, 0.98, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 22
|
||||
autowrap_mode = 3
|
||||
text = "Quests:
|
||||
Loading…"
|
||||
|
||||
[node name="QuestAcceptFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000026]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.9, 0.86, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 22
|
||||
autowrap_mode = 3
|
||||
text = "Quest accept: — (Q gather / Shift+Q next)"
|
||||
|
||||
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000020]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.88, 0.82, 0.98, 1)
|
||||
|
|
@ -1234,7 +1270,7 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "NPC state: —"
|
||||
|
||||
[node name="TelegraphLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000021]
|
||||
[node name="TelegraphLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000021]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(1, 0.72, 0.55, 1)
|
||||
|
|
@ -1244,7 +1280,7 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
text = "Telegraph: —"
|
||||
|
||||
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000008]
|
||||
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000008]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1)
|
||||
|
|
@ -1254,7 +1290,7 @@ theme_override_font_sizes/font_size = 20
|
|||
autowrap_mode = 3
|
||||
text = "Gather: —"
|
||||
|
||||
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000010]
|
||||
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000010]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1)
|
||||
|
|
@ -1264,7 +1300,7 @@ theme_override_font_sizes/font_size = 20
|
|||
autowrap_mode = 3
|
||||
text = "Craft: —"
|
||||
|
||||
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000006]
|
||||
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas/HudRootScroll/HudRoot" unique_id=9000006]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
|
||||
|
|
@ -1274,24 +1310,56 @@ theme_override_font_sizes/font_size = 20
|
|||
autowrap_mode = 3
|
||||
text = "Cooldowns: —"
|
||||
|
||||
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas/HudRoot" unique_id=9000014]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
|
||||
z_index = 10
|
||||
offset_left = 8.0
|
||||
offset_top = 400.0
|
||||
offset_right = 664.0
|
||||
offset_bottom = 800.0
|
||||
script = ExtResource("20_economy_hud")
|
||||
|
||||
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000015]
|
||||
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000015]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="ToggleButton" type="CheckButton" parent="UICanvas/HudRoot/EconomyHudSection/HeaderRow" unique_id=9000016]
|
||||
[node name="ToggleButton" type="CheckButton" parent="UICanvas/EconomyHudSection/HeaderRow" unique_id=9000016]
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Economy HUD"
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000017]
|
||||
[node name="BodyScroll" type="ScrollContainer" parent="UICanvas/EconomyHudSection" unique_id=9000027]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
custom_minimum_size = Vector2(656, 380)
|
||||
horizontal_scroll_mode = 0
|
||||
vertical_scroll_mode = 1
|
||||
follow_focus = true
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll" unique_id=9000017]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="InventoryLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000007]
|
||||
[node name="RecipesHeaderLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000028]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Recipes:"
|
||||
|
||||
[node name="CraftRecipePanel" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000011]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("19_craft_panel")
|
||||
|
||||
[node name="RecipeRows" type="VBoxContainer" parent="UICanvas/EconomyHudSection/BodyScroll/Body/CraftRecipePanel" unique_id=9000013]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="InventoryLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000007]
|
||||
custom_minimum_size = Vector2(656, 0)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
@ -1303,7 +1371,7 @@ autowrap_mode = 3
|
|||
text = "Inventory: (refresh: I)
|
||||
Loading…"
|
||||
|
||||
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000009]
|
||||
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000009]
|
||||
custom_minimum_size = Vector2(656, 0)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
@ -1315,7 +1383,7 @@ autowrap_mode = 3
|
|||
text = "Skills:
|
||||
Loading…"
|
||||
|
||||
[node name="GigXpLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000018]
|
||||
[node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/BodyScroll/Body" unique_id=9000018]
|
||||
custom_minimum_size = Vector2(656, 0)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
@ -1326,22 +1394,3 @@ theme_override_font_sizes/font_size = 20
|
|||
autowrap_mode = 3
|
||||
text = "Gig XP:
|
||||
Loading…"
|
||||
|
||||
[node name="CraftRecipePanel" type="Control" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000011]
|
||||
custom_minimum_size = Vector2(656, 320)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("19_craft_panel")
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel" unique_id=9000012]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel/ScrollContainer" unique_id=9000013]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/separation = 8
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
extends Control
|
||||
extends VBoxContainer
|
||||
|
||||
## NEO-74: scrollable prototype recipe list with per-row Craft buttons.
|
||||
## NEO-74: prototype recipe list with per-row Craft buttons.
|
||||
## NEO-122: rows live in economy BodyScroll (no nested scroll).
|
||||
|
||||
signal craft_requested(recipe_id: String)
|
||||
|
||||
const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd")
|
||||
const HudThemeConstants := preload("res://scripts/prototype_hud_theme_constants.gd")
|
||||
|
||||
var _item_defs_client: Node = null
|
||||
var _craft_buttons: Array[Button] = []
|
||||
|
||||
@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer
|
||||
@onready var _rows: VBoxContainer = $RecipeRows
|
||||
|
||||
|
||||
func setup(item_defs_client: Node) -> void:
|
||||
|
|
@ -17,9 +19,9 @@ func setup(item_defs_client: Node) -> void:
|
|||
|
||||
|
||||
func populate(recipes: Array) -> void:
|
||||
if not is_instance_valid(_vbox):
|
||||
if not is_instance_valid(_rows):
|
||||
return
|
||||
for child in _vbox.get_children():
|
||||
for child in _rows.get_children():
|
||||
child.queue_free()
|
||||
_craft_buttons.clear()
|
||||
for row_variant in recipes:
|
||||
|
|
@ -29,21 +31,26 @@ func populate(recipes: Array) -> void:
|
|||
var recipe_id: String = str(recipe.get("id", ""))
|
||||
if recipe_id.is_empty():
|
||||
continue
|
||||
var row := HBoxContainer.new()
|
||||
var row := VBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
var info := Label.new()
|
||||
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
info.text = _format_recipe_line(recipe)
|
||||
PrototypeHudTheme.apply_to_label(info, false)
|
||||
_apply_recipe_row_label(info)
|
||||
row.add_child(info)
|
||||
var btn_row := HBoxContainer.new()
|
||||
btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
btn_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
var craft_btn := Button.new()
|
||||
craft_btn.text = "Craft"
|
||||
PrototypeHudTheme.apply_to_button(craft_btn, false)
|
||||
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
|
||||
row.add_child(craft_btn)
|
||||
_vbox.add_child(row)
|
||||
btn_row.add_child(craft_btn)
|
||||
row.add_child(btn_row)
|
||||
_rows.add_child(row)
|
||||
_craft_buttons.append(craft_btn)
|
||||
call_deferred("_scroll_economy_body_to_recipes")
|
||||
|
||||
|
||||
func set_craft_busy(busy: bool) -> void:
|
||||
|
|
@ -85,3 +92,18 @@ func _item_display_name(item_id: String) -> String:
|
|||
|
||||
func _on_craft_pressed(recipe_id: String) -> void:
|
||||
craft_requested.emit(recipe_id)
|
||||
|
||||
|
||||
func _apply_recipe_row_label(label: Label) -> void:
|
||||
label.add_theme_font_size_override("font_size", HudThemeConstants.FONT_SIZE_SECONDARY)
|
||||
label.add_theme_constant_override("outline_size", HudThemeConstants.OUTLINE_SIZE)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
|
||||
|
||||
func _scroll_economy_body_to_recipes() -> void:
|
||||
var node: Node = get_parent()
|
||||
while node != null:
|
||||
if node.has_method("scroll_body_to_recipes"):
|
||||
node.call("scroll_body_to_recipes")
|
||||
return
|
||||
node = node.get_parent()
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ extends Node3D
|
|||
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
|
||||
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
|
||||
## under `World` only (not nav source geometry).
|
||||
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/HudRoot/PlayerPositionLabel`
|
||||
## (updated in `_physics_process` so it matches physics ticks).
|
||||
## Prototype HUD: player position in PlayerPositionLabel (HudRootScroll/HudRoot);
|
||||
## updated in `_physics_process` to match physics ticks.
|
||||
|
||||
const MOVE_REJECT_MSG_SECONDS: float = 4.0
|
||||
|
||||
|
|
@ -56,6 +56,7 @@ const REFINE_SKILL_ID := "refine"
|
|||
const BREACH_GIG_ID := "breach"
|
||||
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
|
||||
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
|
||||
const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
|
||||
|
||||
## Bump on each rejection so older one-shot timers do not clear a newer message.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -108,6 +109,7 @@ var _last_gig_snapshot: Dictionary = {}
|
|||
var _gig_error: String = ""
|
||||
var _last_encounter_progress_snapshot: Dictionary = {}
|
||||
var _encounter_progress_error: String = ""
|
||||
var _quest_hud: Node = null
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
var _gather_awaiting_inventory_finalize: bool = false
|
||||
|
|
@ -124,30 +126,34 @@ var _dev_pulse_bootstrap_attempted: bool = false
|
|||
@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot
|
||||
@onready var _radius_preview: Node3D = $World/InteractionMarkers
|
||||
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
|
||||
@onready var _hud_root: VBoxContainer = $UICanvas/HudRoot
|
||||
@onready var _player_pos_label: Label = $UICanvas/HudRoot/PlayerPositionLabel
|
||||
@onready var _target_lock_label: Label = $UICanvas/HudRoot/TargetLockLabel
|
||||
@onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel
|
||||
@onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
|
||||
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel
|
||||
@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel
|
||||
@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel
|
||||
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
|
||||
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
|
||||
@onready var _economy_hud_section: VBoxContainer = $UICanvas/HudRoot/EconomyHudSection
|
||||
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel")
|
||||
@onready var _gather_feedback_label: Label = $UICanvas/HudRoot/GatherFeedbackLabel
|
||||
@onready var _craft_feedback_label: Label = $UICanvas/HudRoot/CraftFeedbackLabel
|
||||
@onready
|
||||
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
|
||||
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel")
|
||||
@onready var _craft_recipe_panel: Node = _economy_hud_section.get_node("Body/CraftRecipePanel")
|
||||
@onready var _hud_root: VBoxContainer = $UICanvas/HudRootScroll/HudRoot
|
||||
@onready var _player_pos_label: Label = _hud_root.get_node("PlayerPositionLabel")
|
||||
@onready var _target_lock_label: Label = _hud_root.get_node("TargetLockLabel")
|
||||
@onready var _cast_feedback_label: Label = _hud_root.get_node("CastFeedbackLabel")
|
||||
@onready var _combat_target_hp_label: Label = _hud_root.get_node("CombatTargetHpLabel")
|
||||
@onready var _player_combat_hp_label: Label = _hud_root.get_node("PlayerCombatHpLabel")
|
||||
@onready var _encounter_progress_label: Label = _hud_root.get_node("EncounterProgressLabel")
|
||||
@onready var _encounter_complete_label: Label = _hud_root.get_node("EncounterCompleteLabel")
|
||||
@onready var _quest_progress_label: Label = _hud_root.get_node("QuestProgressLabel")
|
||||
@onready var _quest_accept_feedback_label: Label = _hud_root.get_node("QuestAcceptFeedbackLabel")
|
||||
@onready var _npc_state_label: Label = _hud_root.get_node("NpcStateLabel")
|
||||
@onready var _telegraph_label: Label = _hud_root.get_node("TelegraphLabel")
|
||||
@onready var _cooldown_slots_label: Label = _hud_root.get_node("CooldownSlotsLabel")
|
||||
@onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
|
||||
@onready var _economy_body: Node = _economy_hud_section.get_node("BodyScroll/Body")
|
||||
@onready var _inventory_label: Label = _economy_body.get_node("InventoryLabel")
|
||||
@onready var _gather_feedback_label: Label = _hud_root.get_node("GatherFeedbackLabel")
|
||||
@onready var _craft_feedback_label: Label = _hud_root.get_node("CraftFeedbackLabel")
|
||||
@onready var _skill_progression_label: Label = _economy_body.get_node("SkillProgressionLabel")
|
||||
@onready var _gig_xp_label: Label = _economy_body.get_node("GigXpLabel")
|
||||
@onready var _craft_recipe_panel: Node = _economy_body.get_node("CraftRecipePanel")
|
||||
@onready var _inventory_client: Node = $InventoryClient
|
||||
@onready var _item_defs_client: Node = $ItemDefinitionsClient
|
||||
@onready var _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _gig_progression_client: Node = $GigProgressionClient
|
||||
@onready var _encounter_progress_client: Node = $EncounterProgressClient
|
||||
@onready var _quest_progress_client: Node = $QuestProgressClient
|
||||
@onready var _quest_defs_client: Node = $QuestDefinitionsClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
|
|
@ -197,6 +203,7 @@ func _ready() -> void:
|
|||
_setup_skill_progression_sync()
|
||||
_setup_gig_progression_sync()
|
||||
_setup_encounter_progress_sync()
|
||||
_setup_quest_progress_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_setup_craft_ui()
|
||||
|
||||
|
|
@ -300,6 +307,8 @@ func _apply_prototype_hud_theme() -> void:
|
|||
PrototypeHudTheme.apply_to_label(_move_reject_label, true)
|
||||
if is_instance_valid(_hud_root):
|
||||
PrototypeHudTheme.apply_to_hud_root(_hud_root)
|
||||
if is_instance_valid(_economy_hud_section):
|
||||
PrototypeHudTheme.apply_to_economy_section(_economy_hud_section)
|
||||
|
||||
|
||||
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
|
||||
|
|
@ -736,6 +745,7 @@ func _on_encounter_progress_received(snapshot: Dictionary) -> void:
|
|||
var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID)
|
||||
if str(row.get("state", "")) == "completed":
|
||||
_request_inventory_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
|
||||
|
||||
func _on_encounter_progress_sync_failed(reason: String) -> void:
|
||||
|
|
@ -842,6 +852,31 @@ func _request_encounter_progress_refresh() -> void:
|
|||
_encounter_progress_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func _setup_quest_progress_sync() -> void:
|
||||
# NEO-122: quest progress + accept HUD; boot hydrate + refresh after gather/craft/defeat.
|
||||
_quest_hud = QuestHudController.new()
|
||||
add_child(_quest_hud)
|
||||
_quest_hud.call(
|
||||
"setup",
|
||||
_quest_progress_client,
|
||||
_quest_defs_client,
|
||||
_quest_progress_label,
|
||||
_quest_accept_feedback_label,
|
||||
Callable(self, "_apply_authority_http_config_to_client")
|
||||
)
|
||||
|
||||
|
||||
func _request_quest_progress_refresh() -> void:
|
||||
if is_instance_valid(_quest_hud):
|
||||
_quest_hud.call("request_progress_refresh")
|
||||
|
||||
|
||||
func _try_quest_accept_key_input(event: InputEvent) -> bool:
|
||||
if is_instance_valid(_quest_hud):
|
||||
return bool(_quest_hud.call("try_accept_key_input", event))
|
||||
return false
|
||||
|
||||
|
||||
func _render_gather_feedback_label(text: String = "Gather: —") -> void:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_gather_feedback_label.text = text
|
||||
|
|
@ -910,6 +945,7 @@ func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void:
|
|||
if bool(result.get("success", false)):
|
||||
_render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", [])))
|
||||
_request_inventory_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
if (
|
||||
is_instance_valid(_skill_progression_client)
|
||||
and _skill_progression_client.has_method("request_sync_from_server")
|
||||
|
|
@ -980,6 +1016,7 @@ func _on_interaction_result_for_gather(
|
|||
return
|
||||
_gather_awaiting_inventory_finalize = true
|
||||
_request_inventory_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
if (
|
||||
is_instance_valid(_skill_progression_client)
|
||||
and _skill_progression_client.has_method("request_sync_from_server")
|
||||
|
|
@ -1143,6 +1180,7 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D
|
|||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
_request_encounter_progress_refresh()
|
||||
_request_quest_progress_refresh()
|
||||
return
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
|
|
@ -1359,6 +1397,8 @@ func _try_route_gameplay_key_input(event: InputEvent) -> bool:
|
|||
return true
|
||||
if _try_interact_key_input(event):
|
||||
return true
|
||||
if _try_quest_accept_key_input(event):
|
||||
return true
|
||||
if _try_inventory_refresh_input(event):
|
||||
return true
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,25 +1,97 @@
|
|||
extends VBoxContainer
|
||||
|
||||
## NEO-75: collapsible economy HUD block (inventory, skills, craft panel).
|
||||
## NEO-122: pinned to viewport bottom-left; clamps HudRootScroll above this panel.
|
||||
|
||||
const MAX_VIEWPORT_HEIGHT_FRACTION := 0.42
|
||||
const MIN_BODY_SCROLL_HEIGHT := 160.0
|
||||
const BOTTOM_MARGIN := 8.0
|
||||
const SIDE_MARGIN := 8.0
|
||||
const PANEL_WIDTH := 656.0
|
||||
const HUD_TOP_MARGIN := 8.0
|
||||
const HUD_ECONOMY_GAP := 12.0
|
||||
const HUD_ROOT_SCROLL_NAME := "HudRootScroll"
|
||||
const PANEL_FILL := Color(0.06, 0.06, 0.1, 0.94)
|
||||
|
||||
@onready var _toggle: CheckButton = $HeaderRow/ToggleButton
|
||||
@onready var _body: VBoxContainer = $Body
|
||||
@onready var _body_scroll: ScrollContainer = $BodyScroll
|
||||
@onready var _recipes_header: Label = $BodyScroll/Body/RecipesHeaderLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
z_index = 10
|
||||
clip_contents = true
|
||||
_toggle.button_pressed = true
|
||||
_body.visible = true
|
||||
_body_scroll.visible = true
|
||||
_toggle.toggled.connect(_on_toggle_toggled)
|
||||
get_viewport().size_changed.connect(_apply_viewport_layout)
|
||||
call_deferred("_apply_viewport_layout")
|
||||
await get_tree().process_frame
|
||||
_apply_viewport_layout()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
draw_rect(Rect2(Vector2.ZERO, size), PANEL_FILL)
|
||||
|
||||
|
||||
func _on_toggle_toggled(pressed: bool) -> void:
|
||||
_body.visible = pressed
|
||||
_body_scroll.visible = pressed
|
||||
call_deferred("_apply_viewport_layout")
|
||||
|
||||
|
||||
func set_expanded(expanded: bool) -> void:
|
||||
_toggle.button_pressed = expanded
|
||||
_body.visible = expanded
|
||||
_body_scroll.visible = expanded
|
||||
call_deferred("_apply_viewport_layout")
|
||||
|
||||
|
||||
func is_body_visible() -> bool:
|
||||
return _body.visible
|
||||
return _body_scroll.visible
|
||||
|
||||
|
||||
func scroll_body_to_recipes() -> void:
|
||||
if not is_instance_valid(_body_scroll) or not is_instance_valid(_recipes_header):
|
||||
return
|
||||
_body_scroll.scroll_vertical = int(_recipes_header.position.y)
|
||||
|
||||
|
||||
func panel_top_y() -> float:
|
||||
return position.y
|
||||
|
||||
|
||||
func _apply_viewport_layout() -> void:
|
||||
if not is_instance_valid(_body_scroll):
|
||||
return
|
||||
var vp_size: Vector2 = get_viewport().get_visible_rect().size
|
||||
if vp_size.y <= 1.0:
|
||||
return
|
||||
var header_height: float = _measure_header_height()
|
||||
var body_height: float = max(
|
||||
MIN_BODY_SCROLL_HEIGHT, vp_size.y * MAX_VIEWPORT_HEIGHT_FRACTION - header_height
|
||||
)
|
||||
_body_scroll.custom_minimum_size = Vector2(PANEL_WIDTH, body_height)
|
||||
var panel_height: float = header_height
|
||||
if _body_scroll.visible:
|
||||
panel_height += body_height
|
||||
position = Vector2(SIDE_MARGIN, vp_size.y - panel_height - BOTTOM_MARGIN)
|
||||
size = Vector2(PANEL_WIDTH, panel_height)
|
||||
_apply_hud_root_scroll_bounds()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _apply_hud_root_scroll_bounds() -> void:
|
||||
var hud_scroll := get_parent().get_node_or_null(HUD_ROOT_SCROLL_NAME)
|
||||
if not hud_scroll is ScrollContainer:
|
||||
return
|
||||
var available_height: float = position.y - HUD_TOP_MARGIN - HUD_ECONOMY_GAP
|
||||
hud_scroll.position = Vector2(SIDE_MARGIN, HUD_TOP_MARGIN)
|
||||
hud_scroll.size = Vector2(PANEL_WIDTH, maxf(0.0, available_height))
|
||||
|
||||
|
||||
func _measure_header_height() -> float:
|
||||
if not is_instance_valid(_toggle):
|
||||
return 32.0
|
||||
var header_row: Node = _toggle.get_parent()
|
||||
if header_row is Control:
|
||||
return max(32.0, (header_row as Control).size.y)
|
||||
return 32.0
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ static func apply_to_hud_root(root: VBoxContainer) -> void:
|
|||
if child is Label:
|
||||
var label: Label = child as Label
|
||||
apply_to_label(label, not Constants.SECONDARY_LABEL_NAMES.has(label.name))
|
||||
elif child.name == "EconomyHudSection":
|
||||
apply_to_economy_section(child)
|
||||
|
||||
|
||||
static func apply_to_economy_section(section: Node) -> void:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
extends Node
|
||||
|
||||
## NEO-122: fetches [code]GET /game/world/quest-definitions[/code] (NEO-115).
|
||||
## Caches ordered quest rows for HUD display names and eligible-quest iteration.
|
||||
|
||||
signal definitions_ready(quests: Array)
|
||||
signal definitions_sync_failed(reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var injected_http: Node = null
|
||||
|
||||
var _http: Node
|
||||
var _busy: bool = false
|
||||
var _quests: Array = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if injected_http != null:
|
||||
_http = injected_http
|
||||
else:
|
||||
_http = HTTPRequest.new()
|
||||
add_child(_http)
|
||||
if _http is HTTPRequest:
|
||||
(_http as HTTPRequest).timeout = 30.0
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_http.request_completed.connect(_on_request_completed)
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _busy:
|
||||
return
|
||||
_busy = true
|
||||
var url := "%s/game/world/quest-definitions" % _base_root()
|
||||
var err: Error = _http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("QuestDefinitionsClient: %s" % reason)
|
||||
_busy = false
|
||||
definitions_sync_failed.emit(reason)
|
||||
|
||||
|
||||
func display_name_for(quest_id: String) -> String:
|
||||
for row_variant in _quests:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("id", "")) == quest_id:
|
||||
var dn := str(row.get("displayName", "")).strip_edges()
|
||||
if not dn.is_empty():
|
||||
return dn
|
||||
return quest_id
|
||||
|
||||
|
||||
func quests_snapshot() -> Array:
|
||||
return _quests.duplicate(true)
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
static func parse_quests_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
var quests: Variant = root.get("quests", null)
|
||||
if quests == null or not quests is Array:
|
||||
return null
|
||||
return quests as Array
|
||||
|
||||
|
||||
func _on_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("QuestDefinitionsClient: %s" % reason)
|
||||
definitions_sync_failed.emit(reason)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("QuestDefinitionsClient: %s" % reason_code)
|
||||
definitions_sync_failed.emit(reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var quests: Variant = parse_quests_json(text)
|
||||
if quests == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("QuestDefinitionsClient: %s" % reason_json)
|
||||
definitions_sync_failed.emit(reason_json)
|
||||
return
|
||||
_quests = (quests as Array).duplicate(true)
|
||||
definitions_ready.emit(_quests.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstdefs01
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
extends Node
|
||||
|
||||
## NEO-122: quest progress + accept HUD wiring (extracted from main.gd).
|
||||
|
||||
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
|
||||
const GATHER_QUEST_ID := "prototype_quest_gather_intro"
|
||||
const ACCEPT_IDLE_HINT := "Quest accept: — (Q gather / Shift+Q next)"
|
||||
const ACCEPT_SENDING_HINT := "Quest accept: sending…"
|
||||
|
||||
var _progress_client: Node = null
|
||||
var _defs_client: Node = null
|
||||
var _progress_label: Label = null
|
||||
var _accept_label: Label = null
|
||||
var _last_snapshot: Dictionary = {}
|
||||
var _progress_error: String = ""
|
||||
var _defs_error: String = ""
|
||||
var _accept_quest_patch: Dictionary = {}
|
||||
|
||||
|
||||
func setup(
|
||||
progress_client: Node,
|
||||
defs_client: Node,
|
||||
progress_label: Label,
|
||||
accept_label: Label,
|
||||
apply_http_config: Callable
|
||||
) -> void:
|
||||
_progress_client = progress_client
|
||||
_defs_client = defs_client
|
||||
_progress_label = progress_label
|
||||
_accept_label = accept_label
|
||||
if apply_http_config.is_valid():
|
||||
apply_http_config.call(progress_client)
|
||||
apply_http_config.call(defs_client)
|
||||
if _progress_client.has_signal("quest_progress_received"):
|
||||
_progress_client.connect("quest_progress_received", Callable(self, "_on_progress_received"))
|
||||
if _progress_client.has_signal("quest_sync_failed"):
|
||||
_progress_client.connect("quest_sync_failed", Callable(self, "_on_sync_failed"))
|
||||
if _progress_client.has_signal("quest_accept_result_received"):
|
||||
_progress_client.connect(
|
||||
"quest_accept_result_received", Callable(self, "_on_accept_result_received")
|
||||
)
|
||||
if _progress_client.has_signal("quest_accept_failed"):
|
||||
_progress_client.connect("quest_accept_failed", Callable(self, "_on_accept_failed"))
|
||||
if _defs_client.has_signal("definitions_ready"):
|
||||
_defs_client.connect("definitions_ready", Callable(self, "_on_definitions_ready"))
|
||||
if _defs_client.has_signal("definitions_sync_failed"):
|
||||
_defs_client.connect(
|
||||
"definitions_sync_failed", Callable(self, "_on_definitions_sync_failed")
|
||||
)
|
||||
_render_progress_label()
|
||||
_render_accept_feedback_label()
|
||||
if _defs_client.has_method("request_sync_from_server"):
|
||||
_defs_client.call("request_sync_from_server")
|
||||
request_progress_refresh()
|
||||
|
||||
|
||||
func request_progress_refresh() -> void:
|
||||
if not is_instance_valid(_progress_client):
|
||||
return
|
||||
if _progress_client.has_method("request_sync_from_server"):
|
||||
_progress_client.call("request_sync_from_server")
|
||||
|
||||
|
||||
func try_accept_key_input(event: InputEvent) -> bool:
|
||||
if not event is InputEventKey:
|
||||
return false
|
||||
var k := event as InputEventKey
|
||||
if not k.pressed or k.echo:
|
||||
return false
|
||||
if k.physical_keycode != KEY_Q and k.keycode != KEY_Q:
|
||||
return false
|
||||
if k.shift_pressed:
|
||||
_request_eligible_accept()
|
||||
else:
|
||||
_request_accept(GATHER_QUEST_ID)
|
||||
return true
|
||||
|
||||
|
||||
func _on_progress_received(snapshot: Dictionary) -> void:
|
||||
_progress_error = ""
|
||||
_last_snapshot = snapshot.duplicate(true)
|
||||
_reapply_accept_quest_patch_if_needed()
|
||||
_render_progress_label()
|
||||
|
||||
|
||||
func _on_sync_failed(reason: String) -> void:
|
||||
_progress_error = reason
|
||||
if _accept_quest_patch.is_empty():
|
||||
_last_snapshot = {}
|
||||
elif _last_snapshot.is_empty():
|
||||
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot({}, _accept_quest_patch)
|
||||
_render_progress_label()
|
||||
|
||||
|
||||
func _on_accept_result_received(quest_id: String, result: Dictionary) -> void:
|
||||
if bool(result.get("accepted", false)):
|
||||
_render_accept_feedback_label("Quest accept: %s accepted" % _display_name(quest_id))
|
||||
var quest_variant: Variant = result.get("quest", null)
|
||||
if quest_variant is Dictionary:
|
||||
_accept_quest_patch = (quest_variant as Dictionary).duplicate(true)
|
||||
_progress_error = ""
|
||||
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot(
|
||||
_last_snapshot, _accept_quest_patch
|
||||
)
|
||||
_render_progress_label()
|
||||
else:
|
||||
var rc := str(result.get("reasonCode", "")).strip_edges()
|
||||
if rc.is_empty():
|
||||
_render_accept_feedback_label("Quest accept: denied (no reasonCode)")
|
||||
else:
|
||||
_render_accept_feedback_label("Quest accept: denied — %s" % rc)
|
||||
request_progress_refresh()
|
||||
|
||||
|
||||
func _on_accept_failed(_quest_id: String, reason: String) -> void:
|
||||
_render_accept_feedback_label("Quest accept: failed — %s" % reason)
|
||||
|
||||
|
||||
func _on_definitions_ready(_quests: Array) -> void:
|
||||
_defs_error = ""
|
||||
if not _last_snapshot.is_empty():
|
||||
_render_progress_label()
|
||||
|
||||
|
||||
func _on_definitions_sync_failed(reason: String) -> void:
|
||||
_defs_error = reason
|
||||
_render_progress_label()
|
||||
|
||||
|
||||
func _render_progress_label() -> void:
|
||||
if not is_instance_valid(_progress_label):
|
||||
return
|
||||
var header := "Quests:"
|
||||
if _last_snapshot.is_empty():
|
||||
var lines: PackedStringArray = [header]
|
||||
if not _progress_error.is_empty():
|
||||
lines.append("error — %s" % _progress_error)
|
||||
if not _defs_error.is_empty():
|
||||
lines.append("definitions error — %s" % _defs_error)
|
||||
lines.append("Loading…")
|
||||
_progress_label.text = "\n".join(lines)
|
||||
return
|
||||
var lines: PackedStringArray = [header]
|
||||
if not _progress_error.is_empty():
|
||||
lines.append("sync error — %s" % _progress_error)
|
||||
if not _defs_error.is_empty():
|
||||
lines.append("definitions error — %s" % _defs_error)
|
||||
var defs: Array = _defs_snapshot()
|
||||
if defs.is_empty():
|
||||
var quests: Variant = _last_snapshot.get("quests", null)
|
||||
if quests is Array:
|
||||
for row_variant in quests as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
var qid: String = str(row.get("questId", ""))
|
||||
lines.append(" %s" % _format_status_line(qid, qid, row))
|
||||
else:
|
||||
for def_variant in defs:
|
||||
if not def_variant is Dictionary:
|
||||
continue
|
||||
var def: Dictionary = def_variant
|
||||
var qid: String = str(def.get("id", ""))
|
||||
if qid.is_empty():
|
||||
continue
|
||||
var row: Dictionary = _quest_row(qid)
|
||||
var label: String = _display_name(qid)
|
||||
lines.append(" %s" % _format_status_line(qid, label, row))
|
||||
_progress_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func _format_status_line(_quest_id: String, display_name: String, row: Dictionary) -> String:
|
||||
var status: String = str(row.get("status", "not_started"))
|
||||
match status:
|
||||
"not_started":
|
||||
return "%s: not started" % display_name
|
||||
"active":
|
||||
var step_index: int = int(row.get("currentStepIndex", 0))
|
||||
var counter_text: String = _format_objective_counters_summary(
|
||||
row.get("objectiveCounters", {})
|
||||
)
|
||||
if counter_text.is_empty():
|
||||
return "%s: active step %d" % [display_name, step_index + 1]
|
||||
return "%s: active step %d %s" % [display_name, step_index + 1, counter_text]
|
||||
"completed":
|
||||
var completed_at: String = str(row.get("completedAt", "")).strip_edges()
|
||||
if completed_at.is_empty():
|
||||
return "%s: completed" % display_name
|
||||
return "%s: completed (%s)" % [display_name, completed_at]
|
||||
_:
|
||||
return "%s: unknown status (%s)" % [display_name, status]
|
||||
|
||||
|
||||
func _format_objective_counters_summary(counters: Variant) -> String:
|
||||
if counters == null or not counters is Dictionary:
|
||||
return ""
|
||||
var d: Dictionary = counters
|
||||
if d.is_empty():
|
||||
return ""
|
||||
var parts: PackedStringArray = PackedStringArray()
|
||||
for key in d.keys():
|
||||
parts.append("%s=%s" % [str(key), str(d[key])])
|
||||
return "(%s)" % ", ".join(parts)
|
||||
|
||||
|
||||
func _render_accept_feedback_label(text: String = ACCEPT_IDLE_HINT) -> void:
|
||||
if is_instance_valid(_accept_label):
|
||||
_accept_label.text = text
|
||||
|
||||
|
||||
func _quest_row(quest_id: String) -> Dictionary:
|
||||
if not is_instance_valid(_progress_client):
|
||||
return {}
|
||||
if not _progress_client.has_method("quest_row"):
|
||||
return {}
|
||||
var row: Variant = _progress_client.call("quest_row", quest_id, _last_snapshot)
|
||||
return row as Dictionary
|
||||
|
||||
|
||||
func _reapply_accept_quest_patch_if_needed() -> void:
|
||||
if _accept_quest_patch.is_empty():
|
||||
return
|
||||
var patch_id := str(_accept_quest_patch.get("questId", "")).strip_edges()
|
||||
if patch_id.is_empty():
|
||||
return
|
||||
var row: Dictionary = _quest_row(patch_id)
|
||||
if str(row.get("status", "not_started")) == "not_started":
|
||||
_last_snapshot = QuestProgressClient.merge_quest_row_into_snapshot(
|
||||
_last_snapshot, _accept_quest_patch
|
||||
)
|
||||
else:
|
||||
_accept_quest_patch = {}
|
||||
|
||||
|
||||
func _display_name(quest_id: String) -> String:
|
||||
if not is_instance_valid(_defs_client):
|
||||
return quest_id
|
||||
if not _defs_client.has_method("display_name_for"):
|
||||
return quest_id
|
||||
return str(_defs_client.call("display_name_for", quest_id))
|
||||
|
||||
|
||||
func _defs_snapshot() -> Array:
|
||||
if not is_instance_valid(_defs_client):
|
||||
return []
|
||||
if not _defs_client.has_method("quests_snapshot"):
|
||||
return []
|
||||
return _defs_client.call("quests_snapshot") as Array
|
||||
|
||||
|
||||
func _find_first_eligible_quest_id() -> String:
|
||||
var defs: Array = _defs_snapshot()
|
||||
for def_variant in defs:
|
||||
if not def_variant is Dictionary:
|
||||
continue
|
||||
var def: Dictionary = def_variant
|
||||
var qid: String = str(def.get("id", ""))
|
||||
if qid.is_empty():
|
||||
continue
|
||||
if _quest_status(qid) != "not_started":
|
||||
continue
|
||||
var prereqs: Variant = def.get("prerequisiteQuestIds", [])
|
||||
if not prereqs is Array:
|
||||
continue
|
||||
var prereqs_met := true
|
||||
for p_variant in prereqs as Array:
|
||||
var pid: String = str(p_variant)
|
||||
if _quest_status(pid) != "completed":
|
||||
prereqs_met = false
|
||||
break
|
||||
if prereqs_met:
|
||||
return qid
|
||||
return ""
|
||||
|
||||
|
||||
func _quest_status(quest_id: String) -> String:
|
||||
var row: Dictionary = _quest_row(quest_id)
|
||||
if row.is_empty():
|
||||
return "not_started"
|
||||
return str(row.get("status", "not_started"))
|
||||
|
||||
|
||||
func _request_accept(quest_id: String) -> bool:
|
||||
if not is_instance_valid(_progress_client):
|
||||
_render_accept_feedback_label("Quest accept: failed — client unavailable")
|
||||
return false
|
||||
if (
|
||||
_progress_client.has_method("is_accept_busy")
|
||||
and bool(_progress_client.call("is_accept_busy"))
|
||||
):
|
||||
_render_accept_feedback_label("Quest accept: busy — try again")
|
||||
return false
|
||||
if not _progress_client.has_method("request_accept"):
|
||||
_render_accept_feedback_label("Quest accept: failed — client unavailable")
|
||||
return false
|
||||
var started: bool = bool(_progress_client.call("request_accept", quest_id))
|
||||
if started:
|
||||
_render_accept_feedback_label(ACCEPT_SENDING_HINT)
|
||||
else:
|
||||
_render_accept_feedback_label("Quest accept: failed — could not start")
|
||||
return started
|
||||
|
||||
|
||||
func _request_eligible_accept() -> void:
|
||||
if not _progress_error.is_empty():
|
||||
_render_accept_feedback_label("Quest accept: progress error — try again")
|
||||
return
|
||||
if _progress_error.is_empty() and _last_snapshot.is_empty():
|
||||
_render_accept_feedback_label("Quest accept: progress loading — try again")
|
||||
return
|
||||
if not _defs_error.is_empty() and _defs_snapshot().is_empty():
|
||||
_render_accept_feedback_label("Quest accept: definitions error — try again")
|
||||
return
|
||||
if _defs_error.is_empty() and _defs_snapshot().is_empty():
|
||||
_render_accept_feedback_label("Quest accept: definitions loading — try again")
|
||||
return
|
||||
var eligible_id: String = _find_first_eligible_quest_id()
|
||||
if eligible_id.is_empty():
|
||||
_render_accept_feedback_label("Quest accept: no eligible quest")
|
||||
return
|
||||
_request_accept(eligible_id)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c8k3m2questhudctrl01
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
extends Node
|
||||
|
||||
## NEO-122: HTTP client for quest-progress GET (NEO-119) and quest accept POST (NEO-120).
|
||||
|
||||
signal quest_progress_received(snapshot: Dictionary)
|
||||
signal quest_sync_failed(reason: String)
|
||||
signal quest_accept_result_received(quest_id: String, result: Dictionary)
|
||||
signal quest_accept_failed(quest_id: String, reason: String)
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
|
||||
@export var base_url: String = "http://127.0.0.1:5253"
|
||||
@export var dev_player_id: String = "dev-local-1"
|
||||
@export var injected_sync_http: Node = null
|
||||
@export var injected_accept_http: Node = null
|
||||
|
||||
var _sync_http: Node
|
||||
var _accept_http: Node
|
||||
var _sync_busy: bool = false
|
||||
var _sync_pending: bool = false
|
||||
var _accept_busy: bool = false
|
||||
var _current_accept_quest_id: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_sync_http = _resolve_http(injected_sync_http)
|
||||
_accept_http = _resolve_http(injected_accept_http)
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_sync_http.request_completed.connect(_on_sync_request_completed)
|
||||
@warning_ignore("unsafe_method_access")
|
||||
_accept_http.request_completed.connect(_on_accept_request_completed)
|
||||
|
||||
|
||||
func is_accept_busy() -> bool:
|
||||
return _accept_busy
|
||||
|
||||
|
||||
func request_sync_from_server() -> void:
|
||||
if _sync_busy:
|
||||
_sync_pending = true
|
||||
return
|
||||
_start_sync_request()
|
||||
|
||||
|
||||
func _start_sync_request() -> void:
|
||||
_sync_busy = true
|
||||
var url := "%s/game/players/%s/quest-progress" % [_base_root(), _player_path_segment()]
|
||||
var err: Error = _sync_http.request(url)
|
||||
if err != OK:
|
||||
var reason := "GET failed to start (%s)" % err
|
||||
push_warning("QuestProgressClient: %s" % reason)
|
||||
_sync_busy = false
|
||||
quest_sync_failed.emit(reason)
|
||||
_try_flush_pending_sync()
|
||||
|
||||
|
||||
## Returns [code]true[/code] when the HTTP POST was queued.
|
||||
func request_accept(quest_id: String) -> bool:
|
||||
if _accept_busy:
|
||||
return false
|
||||
var qid := quest_id.strip_edges()
|
||||
if qid.is_empty():
|
||||
return false
|
||||
_current_accept_quest_id = qid
|
||||
_accept_busy = true
|
||||
var payload: Dictionary = {"schemaVersion": SCHEMA_VERSION}
|
||||
var url := "%s/game/players/%s/quests/%s/accept" % [_base_root(), _player_path_segment(), qid]
|
||||
var headers := PackedStringArray(["Content-Type: application/json"])
|
||||
var err: Error = _accept_http.request(
|
||||
url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)
|
||||
)
|
||||
if err != OK:
|
||||
_accept_busy = false
|
||||
_current_accept_quest_id = ""
|
||||
push_warning("QuestProgressClient: POST failed to start (%s)" % err)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func quest_row(quest_id: String, snapshot: Dictionary = {}) -> Dictionary:
|
||||
var quests: Variant = snapshot.get("quests", null)
|
||||
if quests == null or not quests is Array:
|
||||
return {}
|
||||
for row_variant in quests as Array:
|
||||
if not row_variant is Dictionary:
|
||||
continue
|
||||
var row: Dictionary = row_variant
|
||||
if str(row.get("questId", "")) == quest_id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _resolve_http(injected: Node) -> Node:
|
||||
if injected != null:
|
||||
return injected
|
||||
var http := HTTPRequest.new()
|
||||
add_child(http)
|
||||
http.timeout = 30.0
|
||||
return http
|
||||
|
||||
|
||||
func _base_root() -> String:
|
||||
return base_url.strip_edges().rstrip("/")
|
||||
|
||||
|
||||
func _player_path_segment() -> String:
|
||||
return dev_player_id.strip_edges()
|
||||
|
||||
|
||||
static func parse_quest_progress_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
var quests: Variant = root.get("quests", null)
|
||||
if quests == null or not quests is Array:
|
||||
return null
|
||||
if str(root.get("playerId", "")).strip_edges().is_empty():
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
static func parse_quest_accept_json(text: String) -> Variant:
|
||||
var parsed: Variant = JSON.parse_string(text)
|
||||
if not parsed is Dictionary:
|
||||
return null
|
||||
var root: Dictionary = parsed
|
||||
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
|
||||
return null
|
||||
if not root.has("accepted"):
|
||||
return null
|
||||
return root
|
||||
|
||||
|
||||
static func merge_quest_row_into_snapshot(snapshot: Dictionary, row: Dictionary) -> Dictionary:
|
||||
var qid := str(row.get("questId", "")).strip_edges()
|
||||
if qid.is_empty():
|
||||
return snapshot.duplicate(true) if not snapshot.is_empty() else {}
|
||||
var merged: Dictionary
|
||||
if snapshot.is_empty():
|
||||
merged = {"schemaVersion": SCHEMA_VERSION, "playerId": "", "quests": []}
|
||||
else:
|
||||
merged = snapshot.duplicate(true)
|
||||
var quests: Array = []
|
||||
var quests_variant: Variant = merged.get("quests", null)
|
||||
if quests_variant is Array:
|
||||
for row_variant in quests_variant as Array:
|
||||
quests.append(row_variant)
|
||||
var replaced := false
|
||||
for i in quests.size():
|
||||
var existing: Variant = quests[i]
|
||||
if existing is Dictionary and str((existing as Dictionary).get("questId", "")) == qid:
|
||||
quests[i] = row.duplicate(true)
|
||||
replaced = true
|
||||
break
|
||||
if not replaced:
|
||||
quests.append(row.duplicate(true))
|
||||
merged["quests"] = quests
|
||||
return merged
|
||||
|
||||
|
||||
func _try_flush_pending_sync() -> void:
|
||||
if not _sync_pending or _sync_busy:
|
||||
return
|
||||
_sync_pending = false
|
||||
_start_sync_request()
|
||||
|
||||
|
||||
func _on_sync_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
_sync_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("QuestProgressClient: %s" % reason)
|
||||
quest_sync_failed.emit(reason)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("QuestProgressClient: %s" % reason404)
|
||||
quest_sync_failed.emit(reason404)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("QuestProgressClient: %s" % reason_code)
|
||||
quest_sync_failed.emit(reason_code)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var snapshot: Variant = parse_quest_progress_json(text)
|
||||
if snapshot == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("QuestProgressClient: %s" % reason_json)
|
||||
quest_sync_failed.emit(reason_json)
|
||||
_try_flush_pending_sync()
|
||||
return
|
||||
quest_progress_received.emit(snapshot as Dictionary)
|
||||
_try_flush_pending_sync()
|
||||
|
||||
|
||||
func _on_accept_request_completed(
|
||||
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
|
||||
) -> void:
|
||||
var quest_echo: String = _current_accept_quest_id
|
||||
_current_accept_quest_id = ""
|
||||
_accept_busy = false
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
var reason := "HTTP failed (result=%s)" % result
|
||||
push_warning("QuestProgressClient: %s" % reason)
|
||||
quest_accept_failed.emit(quest_echo, reason)
|
||||
return
|
||||
if response_code == 404:
|
||||
var reason404 := "HTTP 404 (player unknown)"
|
||||
push_warning("QuestProgressClient: %s" % reason404)
|
||||
quest_accept_failed.emit(quest_echo, reason404)
|
||||
return
|
||||
if response_code < 200 or response_code >= 300:
|
||||
var reason_code := "HTTP %s" % response_code
|
||||
push_warning("QuestProgressClient: %s" % reason_code)
|
||||
quest_accept_failed.emit(quest_echo, reason_code)
|
||||
return
|
||||
var text := body.get_string_from_utf8()
|
||||
var parsed: Variant = parse_quest_accept_json(text)
|
||||
if parsed == null:
|
||||
var reason_json := "non-JSON body or schemaVersion mismatch"
|
||||
push_warning("QuestProgressClient: %s" % reason_json)
|
||||
quest_accept_failed.emit(quest_echo, reason_json)
|
||||
return
|
||||
var data: Dictionary = parsed as Dictionary
|
||||
if not bool(data.get("accepted", false)):
|
||||
var reason_variant: Variant = data.get("reasonCode", "")
|
||||
var reason_str: String = reason_variant as String if reason_variant is String else ""
|
||||
push_warning("quest_accept_denied reasonCode=%s" % reason_str)
|
||||
quest_accept_result_received.emit(quest_echo, data.duplicate(true))
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstprog01
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-122 follow-up: craft recipe panel rows + economy body scroll reset.
|
||||
|
||||
const CraftRecipePanel := preload("res://scripts/craft_recipe_panel.gd")
|
||||
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
|
||||
|
||||
|
||||
static func _sample_recipes() -> Array:
|
||||
return [
|
||||
{
|
||||
"id": "refine_scrap_standard",
|
||||
"displayName": "Refine Scrap (Standard)",
|
||||
"inputs": [{"itemId": "scrap_metal_bulk", "quantity": 5}],
|
||||
},
|
||||
{
|
||||
"id": "refine_scrap_efficient",
|
||||
"displayName": "Refine Scrap (Efficient)",
|
||||
"inputs": [{"itemId": "scrap_metal_bulk", "quantity": 10}],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
func _build_panel() -> Control:
|
||||
var panel: Control = CraftRecipePanel.new()
|
||||
var rows := VBoxContainer.new()
|
||||
rows.name = "RecipeRows"
|
||||
panel.add_child(rows)
|
||||
auto_free(panel)
|
||||
add_child(panel)
|
||||
await get_tree().process_frame
|
||||
return panel
|
||||
|
||||
|
||||
func _build_panel_in_economy_section() -> Dictionary:
|
||||
var section: VBoxContainer = EconomyHudSection.new()
|
||||
var header := HBoxContainer.new()
|
||||
header.name = "HeaderRow"
|
||||
var toggle := CheckButton.new()
|
||||
toggle.name = "ToggleButton"
|
||||
header.add_child(toggle)
|
||||
var body_scroll := ScrollContainer.new()
|
||||
body_scroll.name = "BodyScroll"
|
||||
var body := VBoxContainer.new()
|
||||
body.name = "Body"
|
||||
var recipes_header := Label.new()
|
||||
recipes_header.name = "RecipesHeaderLabel"
|
||||
body.add_child(recipes_header)
|
||||
var panel: Control = CraftRecipePanel.new()
|
||||
panel.name = "CraftRecipePanel"
|
||||
var rows := VBoxContainer.new()
|
||||
rows.name = "RecipeRows"
|
||||
panel.add_child(rows)
|
||||
body.add_child(panel)
|
||||
body_scroll.add_child(body)
|
||||
section.add_child(header)
|
||||
section.add_child(body_scroll)
|
||||
auto_free(section)
|
||||
add_child(section)
|
||||
await get_tree().process_frame
|
||||
return {"section": section, "panel": panel, "body_scroll": body_scroll}
|
||||
|
||||
|
||||
func test_populate_builds_one_row_per_recipe() -> void:
|
||||
# Arrange
|
||||
var panel := await _build_panel()
|
||||
# Act
|
||||
panel.call("populate", _sample_recipes())
|
||||
await get_tree().process_frame
|
||||
# Assert
|
||||
var rows: VBoxContainer = panel.get_node("RecipeRows")
|
||||
assert_that(rows.get_child_count()).is_equal(2)
|
||||
|
||||
|
||||
func test_populate_scrolls_economy_body_to_recipes() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_panel_in_economy_section()
|
||||
var panel: Control = built["panel"]
|
||||
var body_scroll: ScrollContainer = built["body_scroll"]
|
||||
body_scroll.scroll_vertical = 999
|
||||
# Act
|
||||
panel.call("populate", _sample_recipes())
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
# Assert
|
||||
assert_that(body_scroll.scroll_vertical).is_equal(0)
|
||||
|
||||
|
||||
func test_populate_emits_craft_requested_for_row() -> void:
|
||||
# Arrange
|
||||
var panel := await _build_panel()
|
||||
var captured: Array = []
|
||||
panel.connect("craft_requested", func(recipe_id: String) -> void: captured.append(recipe_id))
|
||||
panel.call("populate", _sample_recipes())
|
||||
await get_tree().process_frame
|
||||
var rows: VBoxContainer = panel.get_node("RecipeRows")
|
||||
var first_row: VBoxContainer = rows.get_child(0) as VBoxContainer
|
||||
var btn_row: HBoxContainer = first_row.get_child(1) as HBoxContainer
|
||||
var craft_btn: Button = btn_row.get_child(0) as Button
|
||||
# Act
|
||||
craft_btn.pressed.emit()
|
||||
# Assert
|
||||
assert_that(captured.size()).is_equal(1)
|
||||
assert_that(str(captured[0])).is_equal("refine_scrap_standard")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122craftpanel1
|
||||
|
|
@ -29,11 +29,13 @@ func test_display_windup_remaining_decreases_over_time() -> void:
|
|||
state.call("apply_snapshot", _telegraph_snapshot(2.0))
|
||||
var first: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
|
||||
# Act
|
||||
await (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout
|
||||
for _i in 3:
|
||||
await get_tree().process_frame
|
||||
await (Engine.get_main_loop() as SceneTree).create_timer(0.1).timeout
|
||||
var second: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
|
||||
# Assert
|
||||
assert_that(second).is_less(first)
|
||||
assert_that(second).is_greater(1.9)
|
||||
assert_that(second).is_greater(1.85)
|
||||
|
||||
|
||||
func test_new_snapshot_reconciles_windup_anchor() -> void:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-75: economy HUD collapse toggle shows/hides body container.
|
||||
## Covers economy HUD node paths via EconomyHudSection.get_node in main.gd.
|
||||
## NEO-75: economy HUD collapse toggle shows/hides body scroll container.
|
||||
## NEO-122: BodyScroll/Body paths match main.tscn; HudRootScroll clamp on layout.
|
||||
|
||||
const EconomyHudSection := preload("res://scripts/prototype_economy_hud_section.gd")
|
||||
|
||||
const TEST_VIEWPORT_SIZE := Vector2i(1280, 720)
|
||||
|
||||
|
||||
func _build_section() -> Node:
|
||||
var section: VBoxContainer = EconomyHudSection.new()
|
||||
|
|
@ -14,16 +16,27 @@ func _build_section() -> Node:
|
|||
toggle.name = "ToggleButton"
|
||||
toggle.text = "Economy HUD"
|
||||
header.add_child(toggle)
|
||||
var body_scroll := ScrollContainer.new()
|
||||
body_scroll.name = "BodyScroll"
|
||||
var body := VBoxContainer.new()
|
||||
body.name = "Body"
|
||||
var recipes_header := Label.new()
|
||||
recipes_header.name = "RecipesHeaderLabel"
|
||||
body.add_child(recipes_header)
|
||||
var inventory := Label.new()
|
||||
inventory.name = "InventoryLabel"
|
||||
body.add_child(inventory)
|
||||
body_scroll.add_child(body)
|
||||
section.add_child(header)
|
||||
section.add_child(body)
|
||||
section.add_child(body_scroll)
|
||||
return section
|
||||
|
||||
|
||||
func _set_test_viewport_size(size: Vector2i = TEST_VIEWPORT_SIZE) -> void:
|
||||
get_tree().root.size = size
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func test_body_visible_when_expanded_by_default() -> void:
|
||||
# Arrange
|
||||
var section := _build_section()
|
||||
|
|
@ -59,3 +72,43 @@ func test_toggle_expands_body_again() -> void:
|
|||
await await_idle_frame()
|
||||
# Assert
|
||||
assert_bool(section.call("is_body_visible")).is_true()
|
||||
|
||||
|
||||
func test_viewport_layout_pins_section_above_bottom_margin() -> void:
|
||||
# Arrange
|
||||
await _set_test_viewport_size()
|
||||
var section: Control = _build_section() as Control
|
||||
add_child(section)
|
||||
await await_idle_frame()
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
section.call("_apply_viewport_layout")
|
||||
await get_tree().process_frame
|
||||
# Assert
|
||||
var vp_height: float = get_viewport().get_visible_rect().size.y
|
||||
assert_that(section.position.y).is_greater_equal(0.0)
|
||||
assert_that(section.position.y).is_greater(vp_height * 0.2)
|
||||
assert_that(section.position.y + section.size.y).is_less_equal(vp_height)
|
||||
|
||||
|
||||
func test_viewport_layout_clamps_hud_root_scroll() -> void:
|
||||
# Arrange
|
||||
await _set_test_viewport_size()
|
||||
var canvas := Control.new()
|
||||
auto_free(canvas)
|
||||
canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
var hud_scroll := ScrollContainer.new()
|
||||
hud_scroll.name = "HudRootScroll"
|
||||
canvas.add_child(hud_scroll)
|
||||
var section: Control = _build_section() as Control
|
||||
canvas.add_child(section)
|
||||
add_child(canvas)
|
||||
await await_idle_frame()
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
section.call("_apply_viewport_layout")
|
||||
await get_tree().process_frame
|
||||
# Assert
|
||||
var panel_top: float = section.call("panel_top_y")
|
||||
assert_that(panel_top).is_greater(0.0)
|
||||
assert_that(hud_scroll.size.y).is_less_equal(panel_top - 12.0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-122: `QuestDefinitionsClient` GET parse + display names.
|
||||
|
||||
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
|
||||
|
||||
var _quests_capture: Array = []
|
||||
|
||||
|
||||
func _capture_quests(quests: Array) -> void:
|
||||
_quests_capture = quests
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _four_quests_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"quests":['
|
||||
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
|
||||
+ '"prerequisiteQuestIds":[],"steps":[]},'
|
||||
+ '{"id":"prototype_quest_refine_intro","displayName":"Intro: Refine Stock",'
|
||||
+ '"prerequisiteQuestIds":["prototype_quest_gather_intro"],"steps":[]},'
|
||||
+ '{"id":"prototype_quest_combat_intro","displayName":"Intro: Clear the Pocket",'
|
||||
+ '"prerequisiteQuestIds":[],"steps":[]},'
|
||||
+ '{"id":"prototype_quest_operator_chain","displayName":"Operator Chain",'
|
||||
+ '"prerequisiteQuestIds":["prototype_quest_gather_intro",'
|
||||
+ '"prototype_quest_refine_intro","prototype_quest_combat_intro"],"steps":[]}'
|
||||
+ "]}"
|
||||
)
|
||||
|
||||
|
||||
func _make_client(transport: Node) -> Node:
|
||||
var c: Node = QuestDefinitionsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func test_parse_quests_json_reads_four_quests() -> void:
|
||||
# Arrange
|
||||
var json := _four_quests_json()
|
||||
# Act
|
||||
var quests: Variant = QuestDefinitionsClient.parse_quests_json(json)
|
||||
# Assert
|
||||
assert_that(quests is Array).is_true()
|
||||
assert_that((quests as Array).size()).is_equal(4)
|
||||
|
||||
|
||||
func test_request_sync_emits_definitions_ready() -> void:
|
||||
# Arrange
|
||||
_quests_capture = []
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _four_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.connect("definitions_ready", Callable(self, "_capture_quests"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("definitions_ready", any())
|
||||
assert_that(_quests_capture.size()).is_equal(4)
|
||||
assert_that(transport.last_url).contains("/quest-definitions")
|
||||
|
||||
|
||||
func test_display_name_for_returns_catalog_display_name() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _four_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var name: String = str(c.call("display_name_for", "prototype_quest_gather_intro"))
|
||||
# Assert
|
||||
assert_that(name).is_equal("Intro: Salvage Run")
|
||||
|
||||
|
||||
func test_display_name_for_falls_back_to_raw_id() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _four_quests_json()
|
||||
var c := _make_client(transport)
|
||||
c.call("request_sync_from_server")
|
||||
await get_tree().process_frame
|
||||
# Act
|
||||
var name: String = str(c.call("display_name_for", "unknown_quest_id"))
|
||||
# Assert
|
||||
assert_that(name).is_equal("unknown_quest_id")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qstdeft01
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-122: quest HUD controller accept key + progress label render.
|
||||
|
||||
const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
|
||||
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
|
||||
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
class NoopHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
return OK
|
||||
|
||||
|
||||
class PendingAcceptHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
return OK
|
||||
|
||||
|
||||
class FailStartAcceptHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
return ERR_UNAVAILABLE
|
||||
|
||||
|
||||
func _make_progress_client(sync_transport: Node, accept_transport: Node = null) -> Node:
|
||||
var c: Node = QuestProgressClient.new()
|
||||
c.set("injected_sync_http", sync_transport)
|
||||
var accept_http: Node = accept_transport if accept_transport != null else sync_transport
|
||||
c.set("injected_accept_http", accept_http)
|
||||
auto_free(sync_transport)
|
||||
if accept_transport != null and accept_transport != sync_transport:
|
||||
auto_free(accept_transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func _make_defs_client(transport: Node) -> Node:
|
||||
var c: Node = QuestDefinitionsClient.new()
|
||||
c.set("injected_http", transport)
|
||||
auto_free(transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
func _build_controller(
|
||||
sync_transport: Node, accept_transport: Node, defs_transport: Node
|
||||
) -> Dictionary:
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress: Node = _make_progress_client(sync_transport, accept_transport)
|
||||
var defs: Node = _make_defs_client(defs_transport)
|
||||
var progress_label := Label.new()
|
||||
var accept_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
auto_free(accept_label)
|
||||
add_child(controller)
|
||||
await get_tree().process_frame
|
||||
controller.call(
|
||||
"setup", progress, defs, progress_label, accept_label, Callable(self, "_noop_http_config")
|
||||
)
|
||||
await get_tree().process_frame
|
||||
return {
|
||||
"controller": controller,
|
||||
"progress": progress,
|
||||
"accept_label": accept_label,
|
||||
"progress_label": progress_label,
|
||||
}
|
||||
|
||||
|
||||
func _noop_http_config(_client: Node) -> void:
|
||||
pass
|
||||
|
||||
|
||||
func test_try_accept_key_input_handles_q_key() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_controller(
|
||||
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
|
||||
)
|
||||
var event := InputEventKey.new()
|
||||
event.pressed = true
|
||||
event.keycode = KEY_Q
|
||||
# Act
|
||||
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
|
||||
# Assert
|
||||
assert_bool(handled).is_true()
|
||||
assert_str(built["accept_label"].text).contains("sending")
|
||||
|
||||
|
||||
func test_try_accept_key_input_shows_busy_while_accept_in_flight() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_controller(
|
||||
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
|
||||
)
|
||||
var event := InputEventKey.new()
|
||||
event.pressed = true
|
||||
event.keycode = KEY_Q
|
||||
built["controller"].call("try_accept_key_input", event)
|
||||
# Act
|
||||
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
|
||||
# Assert
|
||||
assert_bool(handled).is_true()
|
||||
assert_str(built["accept_label"].text).contains("busy")
|
||||
|
||||
|
||||
func test_try_accept_key_input_shows_failed_when_post_wont_start() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_controller(
|
||||
NoopHttpTransport.new(), FailStartAcceptHttpTransport.new(), NoopHttpTransport.new()
|
||||
)
|
||||
var event := InputEventKey.new()
|
||||
event.pressed = true
|
||||
event.keycode = KEY_Q
|
||||
# Act
|
||||
var handled: bool = bool(built["controller"].call("try_accept_key_input", event))
|
||||
# Assert
|
||||
assert_bool(handled).is_true()
|
||||
assert_str(built["accept_label"].text).contains("could not start")
|
||||
|
||||
|
||||
func test_progress_label_shows_loading_before_snapshot() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_controller(
|
||||
NoopHttpTransport.new(), NoopHttpTransport.new(), NoopHttpTransport.new()
|
||||
)
|
||||
# Act
|
||||
var text: String = str(built["progress_label"].text)
|
||||
# Assert
|
||||
assert_str(text).contains("Quests:")
|
||||
assert_str(text).contains("Loading")
|
||||
|
||||
|
||||
func test_shift_q_shows_progress_loading_before_snapshot() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var accept_label := Label.new()
|
||||
var progress_client: Node = QuestProgressClient.new()
|
||||
var defs_client: Node = QuestDefinitionsClient.new()
|
||||
auto_free(controller)
|
||||
auto_free(accept_label)
|
||||
auto_free(progress_client)
|
||||
auto_free(defs_client)
|
||||
add_child(controller)
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_defs_client", defs_client)
|
||||
controller.set("_accept_label", accept_label)
|
||||
controller.set("_last_snapshot", {})
|
||||
controller.set("_progress_error", "")
|
||||
# Act
|
||||
controller.call("_request_eligible_accept")
|
||||
# Assert
|
||||
assert_str(accept_label.text).contains("progress loading")
|
||||
|
||||
|
||||
func test_shift_q_blocks_when_progress_sync_failed() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var accept_label := Label.new()
|
||||
var progress_client: Node = QuestProgressClient.new()
|
||||
var defs_client: Node = QuestDefinitionsClient.new()
|
||||
auto_free(controller)
|
||||
auto_free(accept_label)
|
||||
auto_free(progress_client)
|
||||
auto_free(defs_client)
|
||||
add_child(controller)
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_defs_client", defs_client)
|
||||
controller.set("_accept_label", accept_label)
|
||||
controller.set("_last_snapshot", {})
|
||||
controller.set("_progress_error", "HTTP 404 (player unknown)")
|
||||
# Act
|
||||
controller.call("_request_eligible_accept")
|
||||
# Assert
|
||||
assert_str(accept_label.text).contains("progress error")
|
||||
|
||||
|
||||
func test_request_accept_shows_busy_feedback() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_controller(
|
||||
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
|
||||
)
|
||||
var controller: Node = built["controller"]
|
||||
var accept_label: Label = built["accept_label"]
|
||||
controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID)
|
||||
# Act
|
||||
var started: bool = bool(controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID))
|
||||
# Assert
|
||||
assert_bool(started).is_false()
|
||||
assert_str(accept_label.text).contains("busy")
|
||||
|
||||
|
||||
func test_request_accept_shows_sending_when_post_starts() -> void:
|
||||
# Arrange
|
||||
var built: Dictionary = await _build_controller(
|
||||
NoopHttpTransport.new(), PendingAcceptHttpTransport.new(), NoopHttpTransport.new()
|
||||
)
|
||||
var controller: Node = built["controller"]
|
||||
var accept_label: Label = built["accept_label"]
|
||||
# Act
|
||||
var started: bool = bool(controller.call("_request_accept", QuestHudController.GATHER_QUEST_ID))
|
||||
# Assert
|
||||
assert_bool(started).is_true()
|
||||
assert_str(accept_label.text).contains("sending")
|
||||
|
||||
|
||||
func _not_started_snapshot() -> Dictionary:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"playerId": "dev-local-1",
|
||||
"quests":
|
||||
[
|
||||
{
|
||||
"questId": QuestHudController.GATHER_QUEST_ID,
|
||||
"status": "not_started",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
func test_accept_success_merges_quest_row_into_progress_label() -> void:
|
||||
# Arrange
|
||||
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
add_child(controller)
|
||||
await get_tree().process_frame
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_progress_label", progress_label)
|
||||
controller.set("_last_snapshot", _not_started_snapshot())
|
||||
# Act
|
||||
(
|
||||
controller
|
||||
. call(
|
||||
"_on_accept_result_received",
|
||||
QuestHudController.GATHER_QUEST_ID,
|
||||
{
|
||||
"accepted": true,
|
||||
"quest":
|
||||
{
|
||||
"questId": QuestHudController.GATHER_QUEST_ID,
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
# Assert
|
||||
assert_str(progress_label.text).contains("active step")
|
||||
|
||||
|
||||
func test_stale_sync_reapplies_accept_quest_patch() -> void:
|
||||
# Arrange
|
||||
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
add_child(controller)
|
||||
await get_tree().process_frame
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_progress_label", progress_label)
|
||||
controller.set("_last_snapshot", _not_started_snapshot())
|
||||
(
|
||||
controller
|
||||
. set(
|
||||
"_accept_quest_patch",
|
||||
{
|
||||
"questId": QuestHudController.GATHER_QUEST_ID,
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
# Act
|
||||
controller.call("_on_progress_received", _not_started_snapshot())
|
||||
# Assert
|
||||
assert_str(progress_label.text).contains("active step")
|
||||
|
||||
|
||||
func test_accept_clears_progress_error_and_shows_active_row() -> void:
|
||||
# Arrange
|
||||
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
add_child(controller)
|
||||
await get_tree().process_frame
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_progress_label", progress_label)
|
||||
controller.set("_last_snapshot", _not_started_snapshot())
|
||||
controller.set("_progress_error", "HTTP 404 (player unknown)")
|
||||
# Act
|
||||
(
|
||||
controller
|
||||
. call(
|
||||
"_on_accept_result_received",
|
||||
QuestHudController.GATHER_QUEST_ID,
|
||||
{
|
||||
"accepted": true,
|
||||
"quest":
|
||||
{
|
||||
"questId": QuestHudController.GATHER_QUEST_ID,
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
# Assert
|
||||
assert_str(progress_label.text).contains("active step")
|
||||
assert_str(progress_label.text).not_contains("error —")
|
||||
|
||||
|
||||
func test_sync_fail_preserves_accept_quest_patch() -> void:
|
||||
# Arrange
|
||||
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
add_child(controller)
|
||||
await get_tree().process_frame
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_progress_label", progress_label)
|
||||
(
|
||||
controller
|
||||
. set(
|
||||
"_accept_quest_patch",
|
||||
{
|
||||
"questId": QuestHudController.GATHER_QUEST_ID,
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
(
|
||||
controller
|
||||
. set(
|
||||
"_last_snapshot",
|
||||
(
|
||||
QuestProgressClient
|
||||
. merge_quest_row_into_snapshot(
|
||||
_not_started_snapshot(),
|
||||
{
|
||||
"questId": QuestHudController.GATHER_QUEST_ID,
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
# Act
|
||||
controller.call("_on_sync_failed", "HTTP 503")
|
||||
# Assert
|
||||
assert_str(progress_label.text).contains("active step")
|
||||
assert_str(progress_label.text).contains("sync error — HTTP 503")
|
||||
|
||||
|
||||
func test_shift_q_shows_definitions_error_when_defs_failed() -> void:
|
||||
# Arrange
|
||||
var controller: Node = QuestHudController.new()
|
||||
var accept_label := Label.new()
|
||||
var progress_client: Node = QuestProgressClient.new()
|
||||
var defs_client: Node = QuestDefinitionsClient.new()
|
||||
auto_free(controller)
|
||||
auto_free(accept_label)
|
||||
auto_free(progress_client)
|
||||
auto_free(defs_client)
|
||||
add_child(controller)
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_defs_client", defs_client)
|
||||
controller.set("_accept_label", accept_label)
|
||||
controller.set("_last_snapshot", _not_started_snapshot())
|
||||
controller.set("_progress_error", "")
|
||||
controller.set("_defs_error", "HTTP 503")
|
||||
# Act
|
||||
controller.call("_request_eligible_accept")
|
||||
# Assert
|
||||
assert_str(accept_label.text).contains("definitions error")
|
||||
|
||||
|
||||
func _gather_defs_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"quests":['
|
||||
+ '{"id":"prototype_quest_gather_intro","displayName":"Intro: Salvage Run",'
|
||||
+ '"prerequisiteQuestIds":[],"steps":[]}]}'
|
||||
)
|
||||
|
||||
|
||||
func test_definitions_ready_repaints_with_display_names_when_progress_error() -> void:
|
||||
# Arrange
|
||||
var defs_transport := MockHttpTransport.new()
|
||||
defs_transport.body_json = _gather_defs_json()
|
||||
var defs_client: Node = _make_defs_client(defs_transport)
|
||||
var progress_client: Node = _make_progress_client(NoopHttpTransport.new())
|
||||
var controller: Node = QuestHudController.new()
|
||||
var progress_label := Label.new()
|
||||
auto_free(controller)
|
||||
auto_free(progress_label)
|
||||
add_child(controller)
|
||||
await get_tree().process_frame
|
||||
defs_client.call("request_sync_from_server")
|
||||
controller.set("_progress_client", progress_client)
|
||||
controller.set("_defs_client", defs_client)
|
||||
controller.set("_progress_label", progress_label)
|
||||
controller.set("_last_snapshot", _not_started_snapshot())
|
||||
controller.set("_progress_error", "HTTP 503")
|
||||
# Act
|
||||
controller.call("_on_definitions_ready", [])
|
||||
# Assert
|
||||
assert_str(progress_label.text).contains("Intro: Salvage Run")
|
||||
assert_str(progress_label.text).not_contains("prototype_quest_gather_intro")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://c8k3m2questhudtest01
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
extends GdUnitTestSuite
|
||||
|
||||
## NEO-122: `QuestProgressClient` GET parse, accept POST, and failure signals.
|
||||
|
||||
const QuestProgressClient := preload("res://scripts/quest_progress_client.gd")
|
||||
|
||||
const PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
|
||||
|
||||
var _progress_capture: Dictionary = {}
|
||||
var _accept_capture: Dictionary = {}
|
||||
var _accept_failed_capture: Dictionary = {}
|
||||
|
||||
|
||||
func _capture_progress(snapshot: Dictionary) -> void:
|
||||
_progress_capture = snapshot
|
||||
|
||||
|
||||
func _capture_accept(quest_id: String, result: Dictionary) -> void:
|
||||
_accept_capture = {"questId": quest_id, "result": result}
|
||||
|
||||
|
||||
func _capture_accept_failed(quest_id: String, reason: String) -> void:
|
||||
_accept_failed_capture = {"questId": quest_id, "reason": reason}
|
||||
|
||||
|
||||
class MockHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var last_method: int = HTTPClient.METHOD_GET
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
last_method = method
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
return OK
|
||||
|
||||
|
||||
static func _not_started_progress_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
|
||||
+ '[{"questId":"prototype_quest_gather_intro","status":"not_started",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{}}]}'
|
||||
)
|
||||
|
||||
|
||||
static func _active_partial_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
|
||||
+ '[{"questId":"prototype_quest_gather_intro","status":"active",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{"gather_intro_obj_scrap":2}}]}'
|
||||
)
|
||||
|
||||
|
||||
static func _completed_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"playerId":"dev-local-1","quests":'
|
||||
+ '[{"questId":"prototype_quest_gather_intro","status":"completed",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{},'
|
||||
+ '"completedAt":"2026-06-07T12:00:00Z"}]}'
|
||||
)
|
||||
|
||||
|
||||
static func _accept_success_json() -> String:
|
||||
return (
|
||||
'{"schemaVersion":1,"accepted":true,"quest":'
|
||||
+ '{"questId":"prototype_quest_gather_intro","status":"active",'
|
||||
+ '"currentStepIndex":0,"objectiveCounters":{}}}'
|
||||
)
|
||||
|
||||
|
||||
static func _accept_deny_json() -> String:
|
||||
return '{"schemaVersion":1,"accepted":false,"reasonCode":"prerequisite_incomplete"}'
|
||||
|
||||
|
||||
func _make_client(sync_transport: Node, accept_transport: Node = null) -> Node:
|
||||
var c: Node = QuestProgressClient.new()
|
||||
c.set("injected_sync_http", sync_transport)
|
||||
var accept_http: Node = accept_transport if accept_transport != null else sync_transport
|
||||
c.set("injected_accept_http", accept_http)
|
||||
auto_free(sync_transport)
|
||||
if accept_transport != null and accept_transport != sync_transport:
|
||||
auto_free(accept_transport)
|
||||
auto_free(c)
|
||||
add_child(c)
|
||||
return c
|
||||
|
||||
|
||||
class NoopHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
return OK
|
||||
|
||||
|
||||
class PendingAcceptHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var last_url: String = ""
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_POST,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
last_url = url
|
||||
return OK
|
||||
|
||||
func complete_pending() -> void:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
|
||||
|
||||
class PendingSyncHttpTransport:
|
||||
extends Node
|
||||
signal request_completed(
|
||||
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
|
||||
)
|
||||
var request_count: int = 0
|
||||
var response_code: int = 200
|
||||
var body_json: String = ""
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_custom_headers: PackedStringArray = PackedStringArray(),
|
||||
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
|
||||
_request_data: String = ""
|
||||
) -> Error:
|
||||
request_count += 1
|
||||
return OK
|
||||
|
||||
func complete_pending() -> void:
|
||||
request_completed.emit(
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
response_code,
|
||||
PackedStringArray(),
|
||||
body_json.to_utf8_buffer()
|
||||
)
|
||||
|
||||
|
||||
func test_parse_not_started_quest_row() -> void:
|
||||
# Arrange
|
||||
var json := _not_started_progress_json()
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
|
||||
assert_that(str(row.get("status", ""))).is_equal("not_started")
|
||||
|
||||
|
||||
func test_parse_active_row_has_objective_counters() -> void:
|
||||
# Arrange
|
||||
var json := _active_partial_json()
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
|
||||
assert_that(str(row.get("status", ""))).is_equal("active")
|
||||
var counters: Variant = row.get("objectiveCounters", null)
|
||||
assert_that(counters is Dictionary).is_true()
|
||||
assert_that(int((counters as Dictionary).get("gather_intro_obj_scrap", 0))).is_equal(2)
|
||||
|
||||
|
||||
func test_parse_completed_row_has_completed_at() -> void:
|
||||
# Arrange
|
||||
var json := _completed_json()
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, snapshot as Dictionary)
|
||||
assert_that(str(row.get("status", ""))).is_equal("completed")
|
||||
assert_that(str(row.get("completedAt", ""))).contains("2026-06-07")
|
||||
|
||||
|
||||
func test_request_sync_emits_quest_progress_received() -> void:
|
||||
# Arrange
|
||||
_progress_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _not_started_progress_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(transport, noop)
|
||||
c.connect("quest_progress_received", Callable(self, "_capture_progress"))
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("quest_progress_received", any())
|
||||
assert_that(_progress_capture.get("playerId", "")).is_equal("dev-local-1")
|
||||
assert_that(transport.last_url).contains("/quest-progress")
|
||||
|
||||
|
||||
func test_http_404_emits_quest_sync_failed() -> void:
|
||||
# Arrange
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(transport, noop)
|
||||
monitor_signals(c)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
await assert_signal(c).is_emitted("quest_sync_failed", "HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_parse_quest_progress_json_returns_null_for_schema_mismatch() -> void:
|
||||
# Arrange
|
||||
var json := '{"schemaVersion":2,"playerId":"dev-local-1","quests":[]}'
|
||||
# Act
|
||||
var snapshot: Variant = QuestProgressClient.parse_quest_progress_json(json)
|
||||
# Assert
|
||||
assert_that(snapshot).is_null()
|
||||
|
||||
|
||||
func test_request_accept_emits_quest_accept_result_received() -> void:
|
||||
# Arrange
|
||||
_accept_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _accept_success_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(noop, transport)
|
||||
c.connect("quest_accept_result_received", Callable(self, "_capture_accept"))
|
||||
# Act
|
||||
var started: bool = bool(c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID))
|
||||
# Assert
|
||||
assert_that(started).is_true()
|
||||
assert_that(_accept_capture.get("questId", "")).is_equal(PROTOTYPE_QUEST_GATHER_ID)
|
||||
var accept_result: Dictionary = _accept_capture.get("result", {}) as Dictionary
|
||||
assert_that(bool(accept_result.get("accepted", false))).is_true()
|
||||
assert_that(transport.last_url).contains("/accept")
|
||||
assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
func test_request_accept_emits_deny_with_reason_code() -> void:
|
||||
# Arrange
|
||||
_accept_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.body_json = _accept_deny_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(noop, transport)
|
||||
c.connect("quest_accept_result_received", Callable(self, "_capture_accept"))
|
||||
# Act
|
||||
c.call("request_accept", "prototype_quest_refine_intro")
|
||||
# Assert
|
||||
var result: Dictionary = _accept_capture.get("result", {}) as Dictionary
|
||||
assert_that(bool(result.get("accepted", true))).is_false()
|
||||
assert_that(str(result.get("reasonCode", ""))).is_equal("prerequisite_incomplete")
|
||||
|
||||
|
||||
func test_http_404_on_accept_emits_quest_accept_failed() -> void:
|
||||
# Arrange
|
||||
_accept_failed_capture = {}
|
||||
var transport := MockHttpTransport.new()
|
||||
transport.response_code = 404
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(noop, transport)
|
||||
c.connect("quest_accept_failed", Callable(self, "_capture_accept_failed"))
|
||||
# Act
|
||||
c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID)
|
||||
# Assert
|
||||
assert_that(_accept_failed_capture.get("questId", "")).is_equal(PROTOTYPE_QUEST_GATHER_ID)
|
||||
assert_that(str(_accept_failed_capture.get("reason", ""))).is_equal("HTTP 404 (player unknown)")
|
||||
|
||||
|
||||
func test_request_accept_returns_false_when_accept_busy() -> void:
|
||||
# Arrange
|
||||
var pending := PendingAcceptHttpTransport.new()
|
||||
pending.body_json = _accept_success_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(noop, pending)
|
||||
# Act
|
||||
var first: bool = bool(c.call("request_accept", PROTOTYPE_QUEST_GATHER_ID))
|
||||
var second: bool = bool(c.call("request_accept", "prototype_quest_refine_intro"))
|
||||
# Assert
|
||||
assert_that(first).is_true()
|
||||
assert_that(second).is_false()
|
||||
pending.complete_pending()
|
||||
|
||||
|
||||
func test_merge_quest_row_into_snapshot_replaces_existing_row() -> void:
|
||||
# Arrange
|
||||
var snapshot: Dictionary = {
|
||||
"schemaVersion": 1,
|
||||
"playerId": "dev-local-1",
|
||||
"quests":
|
||||
[
|
||||
{
|
||||
"questId": PROTOTYPE_QUEST_GATHER_ID,
|
||||
"status": "not_started",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
var active_row: Dictionary = {
|
||||
"questId": PROTOTYPE_QUEST_GATHER_ID,
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {},
|
||||
}
|
||||
# Act
|
||||
var merged: Dictionary = QuestProgressClient.merge_quest_row_into_snapshot(snapshot, active_row)
|
||||
# Assert
|
||||
var c: Node = QuestProgressClient.new()
|
||||
auto_free(c)
|
||||
var row: Dictionary = c.call("quest_row", PROTOTYPE_QUEST_GATHER_ID, merged)
|
||||
assert_that(str(row.get("status", ""))).is_equal("active")
|
||||
|
||||
|
||||
func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void:
|
||||
# Arrange
|
||||
var transport := PendingSyncHttpTransport.new()
|
||||
transport.body_json = _not_started_progress_json()
|
||||
var noop := NoopHttpTransport.new()
|
||||
var c := _make_client(transport, noop)
|
||||
# Act
|
||||
c.call("request_sync_from_server")
|
||||
c.call("request_sync_from_server")
|
||||
# Assert
|
||||
assert_that(transport.request_count).is_equal(1)
|
||||
transport.complete_pending()
|
||||
assert_that(transport.request_count).is_equal(2)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo122qsttest01
|
||||
|
|
@ -32,6 +32,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
|
||||
**Prototype E7 Slice 1 — quests (NEO-112):** CI expects **exactly four** quest ids aligned to [E7.M1 quest freeze](../docs/decomposition/modules/E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01) — **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`**, **`prototype_quest_operator_chain`**. Objective **`itemId`** / **`recipeId`** / **`encounterId`** cross-refs must resolve to frozen item, recipe, and encounter catalogs; **`prerequisiteQuestIds`** must be acyclic and reference known quest ids; operator chain terminal step is **`inventory_has_item`** **`contract_handoff_token`** ×1. **Do not rename** quest `id` after ship—change **`displayName`** only. See [E7M1-prototype-backlog.md](../docs/plans/E7M1-prototype-backlog.md) and [NEO-112 plan](../docs/plans/NEO-112-implementation-plan.md).
|
||||
|
||||
**Prototype E7 Slice 2 — quest completion bundles (NEO-124):** Each of the four frozen quest ids must include **`completionRewardBundle`** with **`itemGrants`** / **`skillXpGrants`** per [E7.M2 completion freeze](../docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01). Bundle **`itemGrants[].itemId`** must resolve to the frozen item catalog; **`skillXpGrants[].skillId`** must resolve to a skill that lists **`mission_reward`** in **`allowedXpSourceKinds`**. Omit empty **`itemGrants`**. See [E7M2-prototype-backlog.md](../docs/plans/E7M2-prototype-backlog.md) and [NEO-124 plan](../docs/plans/NEO-124-implementation-plan.md).
|
||||
|
||||
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).
|
||||
|
||||
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
"id": "prototype_quest_gather_intro",
|
||||
"displayName": "Intro: Salvage Run",
|
||||
"prerequisiteQuestIds": [],
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "gather_intro_step_salvage",
|
||||
|
|
@ -24,6 +27,9 @@
|
|||
"id": "prototype_quest_refine_intro",
|
||||
"displayName": "Intro: Refine Stock",
|
||||
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "refine", "amount": 25 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "refine_intro_step_craft",
|
||||
|
|
@ -43,6 +49,9 @@
|
|||
"id": "prototype_quest_combat_intro",
|
||||
"displayName": "Intro: Clear the Pocket",
|
||||
"prerequisiteQuestIds": [],
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "combat_intro_step_encounter",
|
||||
|
|
@ -65,6 +74,10 @@
|
|||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro"
|
||||
],
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "chain_step_gather",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
"$ref": "https://neon-sprawl.local/schemas/quest-step-def.json"
|
||||
},
|
||||
"description": "Ordered steps; prototype Slice 1 uses sequential advance only."
|
||||
},
|
||||
"completionRewardBundle": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
|
||||
"description": "Optional completion payout (E7.M2); required on all four prototype quest ids in CI."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json",
|
||||
"title": "QuestRewardBundle",
|
||||
"description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds v1: itemGrants + skillXpGrants only. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"itemGrants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/reward-grant-row.json"
|
||||
},
|
||||
"description": "Item payout rows; omit when empty (XP-only bundles)."
|
||||
},
|
||||
"skillXpGrants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json"
|
||||
},
|
||||
"description": "Skill XP rows applied via mission_reward on first-time completion."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": ["itemGrants"],
|
||||
"properties": {
|
||||
"itemGrants": {
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": ["skillXpGrants"],
|
||||
"properties": {
|
||||
"skillXpGrants": {
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json",
|
||||
"title": "QuestSkillXpGrant",
|
||||
"description": "Single skill XP row on a quest completionRewardBundle. Apply path uses MissionRewardSkillXpGrant (sourceKind mission_reward) — see E7.M2.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["skillId", "amount"],
|
||||
"properties": {
|
||||
"skillId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "Skill def id from content/skills (prototype Slice 1: frozen trio)."
|
||||
},
|
||||
"amount": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Skill XP granted on first-time quest completion."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,8 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
- Idempotent reward delivery; replays cannot double-claim.
|
||||
- Telemetry hooks: `reward_delivery`, `unlock_granted`.
|
||||
|
||||
**Linear backlog:** [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) — [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Verify quest completion rewards in **Godot**, not Bruno-only. Encounter loot ([E5.M3](../modules/E5_M3_EncounterAndRewardTables.md)) unchanged in Slice 2.
|
||||
|
||||
### Slice 3 - Faction ledger (pre-production)
|
||||
|
||||
- Scope: E7.M3 gating at least one faction line and mission set.
|
||||
|
|
@ -94,4 +96,5 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
## Definition of Done
|
||||
|
||||
- Prototype quest counts and integrated chain meet master plan minimums **in the Godot client** (Slice 1 capstone [NEO-123](../../manual-qa/NEO-123.md)), not Bruno-only.
|
||||
- Slice 2 quest **completion** rewards (items + skill XP) visible in Godot with idempotent delivery (capstone [NEO-132](../../manual-qa/NEO-132.md)), not Bruno-only.
|
||||
- Pre-production adds faction + contracts with validation.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
**Quest catalogs ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** the same script validates each row in `content/quests/*_quests.json` against [`content/schemas/quest-def.schema.json`](../../../content/schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](../../../content/schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](../../../content/schemas/quest-objective-def.schema.json)), rejects **duplicate `id`** across files and duplicate step/objective ids within a quest, cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against frozen item, recipe, and encounter catalogs, enforces **acyclic `prerequisiteQuestIds`**, and (E7 Slice 1) enforces the **frozen four-quest** id set with operator chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1.
|
||||
|
||||
**Quest completion bundles ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** optional **`completionRewardBundle`** on each quest row via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json) (`itemGrants` reuses [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json); `skillXpGrants` via [`quest-skill-xp-grant.schema.json`](../../../content/schemas/quest-skill-xp-grant.schema.json)). E7 Slice 2 requires bundles on all four frozen quest ids with exact freeze-table contents; cross-checks **`itemGrants[].itemId`** and **`skillXpGrants[].skillId`**; each skill XP target must allow **`mission_reward`** in **`allowedXpSourceKinds`**.
|
||||
|
||||
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.
|
||||
|
||||
## Source anchors
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M1 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) registry **landed**; **E7M1-04** [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) HTTP read **landed**; **E7M1-05** [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) player quest state store **landed**; **E7M1-06** [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) **`QuestStateOperations`** **landed**; objective wiring from **E7M1-07** [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||
| **Status** | **Ready** — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) **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**. Client capstone [`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). Epic 7 Slice 1 client capstone complete. |
|
||||
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
|
||||
|
||||
## Purpose
|
||||
|
|
@ -84,7 +84,17 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
|
||||
**Player quest state store (NEO-116):** **`IPlayerQuestStateStore`** — in-memory + Postgres (`V008__player_quest_progress.sql`) per-player rows keyed by `(playerId, questId)`; implicit `not_started` when missing ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116).
|
||||
|
||||
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). HTTP read/accept deferred to E7M1-08/09; objective wiring in E7M1-07.
|
||||
**Quest state operations (NEO-117):** **`QuestStateOperations`** — accept, step advance, complete with **`QuestStateReasonCodes`**; prerequisite gate on accept; idempotent complete at operations layer ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117).
|
||||
|
||||
**Quest objective wiring (NEO-118):** **`QuestObjectiveWiring`** — gather/craft/encounter success hooks + **`inventory_has_item`** on accept/step advance/inventory mutations; auto advance/complete via **`QuestStateOperations`** ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118).
|
||||
|
||||
**Per-player quest progress GET (NEO-119):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; registry + store merge; GET-side **`inventory_has_item`** refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`.
|
||||
|
||||
**Quest accept POST (NEO-120):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; wires **`QuestStateOperations.TryAccept`**; structured **`accepted`** / **`reasonCode`** / optional **`quest`** row ([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/`.
|
||||
|
||||
**Quest telemetry hooks (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).
|
||||
|
||||
**Client quest HUD (NEO-122):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + gather/craft/defeat/accept refresh; **Q** / **Shift+Q** accept bindings ([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).
|
||||
|
||||
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
|
||||
|
||||
|
|
@ -92,6 +102,7 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
|||
|
||||
- Scripting weight: keep `QuestDef` data-first; templates in decomposition/tooling path per epic.
|
||||
- Double-claim: design for idempotency before E7.M2; lint bundles vs E6.M4 / E3.M5.
|
||||
- Epic 7 Slice 1 catalog events (comment-only until E9.M1): **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** — anchored in **`QuestStateOperations`** (NEO-121).
|
||||
|
||||
## Source anchors
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E7.M2 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)). **NEO-43 prep:** server `MissionRewardSkillXpGrant` for scripted skill XP from bundles (`mission_reward`); full router + `QuestRewardBundle` apply still TBD. |
|
||||
| **Status** | Planned — Slice 2 backlog [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md): **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** `MissionRewardSkillXpGrant` for bundle skill XP (`mission_reward`). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory. |
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
@ -38,7 +38,24 @@ Route quest and encounter outputs to XP, items, unlocks, and flags with **idempo
|
|||
|
||||
## Related implementation slices
|
||||
|
||||
Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on replay.
|
||||
Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on replay. Backlog: [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md).
|
||||
|
||||
## Prototype Slice 2 freeze (E7M2-01)
|
||||
|
||||
**`completionRewardBundle`** on each frozen [E7.M1 quest id](E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01). Grant kinds v1: **`itemGrants`** + **`skillXpGrants`** only (unlock/flag rows deferred). Skill XP apply uses **`mission_reward`** via **`MissionRewardSkillXpGrant`** — not stored in content rows.
|
||||
|
||||
| Quest id | Item grants (`itemGrants`) | Skill XP (`skillXpGrants`) |
|
||||
|----------|----------------------------|----------------------------|
|
||||
| `prototype_quest_gather_intro` | — | `salvage` **25** |
|
||||
| `prototype_quest_refine_intro` | — | `refine` **25** |
|
||||
| `prototype_quest_combat_intro` | — | `salvage` **25** |
|
||||
| `prototype_quest_operator_chain` | `survey_drone_kit` ×1 | `salvage` **50** |
|
||||
|
||||
**Idempotency:** `{playerId}:quest_complete:{questId}`. **Encounter loot:** [E5.M3](E5_M3_EncounterAndRewardTables.md) direct grants unchanged in Slice 2.
|
||||
|
||||
**CI enforcement ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** [`scripts/validate_content.py`](../../../scripts/validate_content.py) requires **`completionRewardBundle`** on all four frozen quest ids with exact freeze-table contents; cross-checks **`itemGrants[].itemId`** against the frozen item catalog and **`skillXpGrants[].skillId`** against skills that allow **`mission_reward`** in **`allowedXpSourceKinds`**.
|
||||
|
||||
**Server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** [`QuestDefinitionCatalogLoader`](../../../server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs) mirrors the same E7M2 gates at startup via **`PrototypeE7M2QuestCatalogRules`**; see [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).
|
||||
|
||||
## Source anchors
|
||||
|
||||
|
|
@ -50,3 +67,7 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
|
|||
**NEO-43:** When applying scripted **skill XP** from a mission/quest reward bundle, call **`MissionRewardSkillXpGrant.GrantFromMissionReward`** (`server/NeonSprawl.Server/Game/Skills/`) — fixed **`sourceKind: mission_reward`**, **`skillId`** and **`amount`** from reward data — [NEO-43 implementation plan](../../plans/NEO-43-implementation-plan.md).
|
||||
|
||||
**NEO-107 (E5.M3 producer hook):** On encounter completion grant commit, **`EncounterCompletionOperations.TryCompleteAndGrant`** records **`EncounterCompleteEvent`** in **`IEncounterCompleteEventStore`** (idempotency key **`{playerId}:{encounterId}`**). Future E7.M2 **`QuestRewardBundle`** router consumes this record for **`reward_delivery`** quest credit beyond prototype **`contract_handoff_token`** item loot — comment-only hook stub; no runtime router yet. See [NEO-107 implementation plan](../../plans/NEO-107-implementation-plan.md); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107).
|
||||
|
||||
**NEO-126 (E7M2-03 delivery store):** Quest completion reward delivery uses **`IRewardDeliveryStore`** + **`InMemoryRewardDeliveryStore`** in `server/NeonSprawl.Server/Game/Rewards/` — idempotent **`RewardDeliveryEvent`** per player+quest (idempotency key **`{playerId}:quest_complete:{questId}`**; grant snapshots for item + skill XP rows). Quest-state wiring lands in NEO-128; HTTP **`completionRewardSummary`** in NEO-129. See [NEO-126 implementation plan](../../plans/NEO-126-implementation-plan.md); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126).
|
||||
|
||||
**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).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -107,10 +107,12 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
|||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
|
||||
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Ready |
|
||||
|
||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; **E7M1-03 / NEO-114** registry landed ([NEO-114 plan](../../plans/NEO-114-implementation-plan.md)); **E7M1-04 / NEO-115** HTTP read landed ([NEO-115 plan](../../plans/NEO-115-implementation-plan.md)); **E7M1-05 / NEO-116** player quest state store landed ([NEO-116 plan](../../plans/NEO-116-implementation-plan.md)); **E7M1-06 / NEO-117** **`QuestStateOperations`** landed ([NEO-117 plan](../../plans/NEO-117-implementation-plan.md)); register row stays **Planned** until E7M1-07+ objective wiring and client capstone.
|
||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**; label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. **E7M1-12 / NEO-123** client capstone landed — [`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.
|
||||
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
|
||||
|
||||
**E7.M2 note:** Epic 7 **Slice 2** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. See [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2_RewardAndUnlockRouter.md](E7_M2_RewardAndUnlockRouter.md). Upstream: E7.M1 **Ready**, E2.M2 grant stack ([NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)), E3.M3 inventory **Ready**. Client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). Register row stays **Planned** until capstone lands.
|
||||
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
|
||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown).
|
|||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm boot lines: **`Encounter: … not started (0/3)`** and **`Loot: —`** on **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** (combat HUD block, below **`PlayerCombatHpLabel`**).
|
||||
2. Run Godot main scene (**F5**). Confirm boot lines: **`Encounter: … not started (0/3)`** and **`Loot: —`** on **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** (in **`HudRootScroll`**, below **`PlayerCombatHpLabel`**). Scroll **`HudRootScroll`** if the debug stack is taller than the viewport.
|
||||
3. Defeat **`prototype_npc_melee`**: walk to orange marker, **Tab** lock, press **1** until **`Cast: … — defeated!`**. Verify **`EncounterProgressLabel`** shows **`1/3`** (may lag one GET behind cast — wait ~1 s or defeat next NPC).
|
||||
4. Defeat **`prototype_npc_ranged`**: lock at SE marker, cast until defeated. Verify **`2/3`**.
|
||||
5. Defeat **`prototype_npc_elite`**: lock at origin, cast until defeated (**8** pulses). Verify **`completed (3/3)`** and **`EncounterCompleteLabel`** lists **`scrap_metal_bulk ×10`** and **`contract_handoff_token ×1`** (or raw ids if defs still loading).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
# Manual QA — NEO-118 (E7M1-07 quest objective wiring)
|
||||
|
||||
Server-side verification until [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) exposes **`GET /game/players/{id}/quest-progress`**.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Server running locally (`dotnet run` from `server/NeonSprawl.Server`)
|
||||
- Dev player **`dev-local-1`** with position row (default Development seed)
|
||||
- Automated suite green: `dotnet test server/NeonSprawl.Server.Tests --filter FullyQualifiedName~QuestObjectiveWiringTests`
|
||||
|
||||
## Checklist
|
||||
|
||||
1. [ ] **Gather intro** — Accept is not HTTP yet; use unit test `GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes` or temporary debug accept in REPL/tests. After accept + three alpha-node gathers, progress row for **`prototype_quest_gather_intro`** is **completed** (store test / wiring test).
|
||||
2. [ ] **Refine intro** — With gather intro complete, accept **`prototype_quest_refine_intro`**, craft **`refine_scrap_standard`** via `POST /game/players/dev-local-1/craft`; quest completes (wiring test `RefineIntro_ShouldComplete_WhenRefineScrapStandardCraftSucceeds`).
|
||||
3. [ ] **Combat intro** — Accept **`prototype_quest_combat_intro`**, complete **`prototype_combat_pocket`** via ability cast (NEO-106 path); quest completes (wiring test `CombatIntro_ShouldComplete_WhenEncounterCompletes`).
|
||||
4. [ ] **Operator chain** — With all three intros complete, accept **`prototype_quest_operator_chain`**, perform gather → refine → stim crafts per catalog; terminal **`inventory_has_item`** **`contract_handoff_token`** completes chain (wiring test `ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet`).
|
||||
5. [ ] **Regression** — Full server test suite passes (674+ tests).
|
||||
|
||||
## Notes
|
||||
|
||||
- Player-visible HUD verification lands in [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122).
|
||||
- Capstone Godot loop: [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) / `docs/manual-qa/NEO-123.md`.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Manual QA — NEO-121 (quest telemetry hook sites)
|
||||
|
||||
Reference: [implementation plan](../plans/NEO-121-implementation-plan.md), [NEO-117 implementation plan](../plans/NEO-117-implementation-plan.md) (quest operations), [NEO-118 implementation plan](../plans/NEO-118-implementation-plan.md) (objective wiring).
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`) if exercising Bruno; not required for code review only.
|
||||
- This story adds **comments only**; quest behavior matches NEO-117/118/119/120.
|
||||
|
||||
## Code review check
|
||||
|
||||
- [ ] Open `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs`.
|
||||
- [ ] Confirm **`quest_start`** comment block immediately after successful **`TryActivate`** in **`TryAccept`** (before objective wiring).
|
||||
- [ ] Confirm **`quest_step_complete`** comment block after successful **`TryAdvanceStep`** store commit.
|
||||
- [ ] Confirm **`quest_complete`** comment block after first-time **`TryMarkComplete`** commit (not on idempotent **`completed`** early return).
|
||||
- [ ] Confirm **`quest_accept_denied`** comment in **`DenyAccept`** (used by all **`TryAccept`** deny paths).
|
||||
- [ ] Confirm **`TODO(E9.M1)`** markers and planned payload fields; **no** new `ILogger` or metrics calls.
|
||||
- [ ] Confirm **no** duplicate hook comments in **`QuestAcceptApi`**, **`QuestProgressApi`**, or **`QuestObjectiveWiring`**.
|
||||
- [ ] Open [server README — Quest telemetry hooks (NEO-121)](../../server/README.md#quest-telemetry-hooks-neo-121) — NEO-121 subsection present with event table.
|
||||
|
||||
## Regression (optional)
|
||||
|
||||
```bash
|
||||
cd server && dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Expect all tests pass (comments-only change).
|
||||
|
||||
## Bruno (optional sanity)
|
||||
|
||||
From `bruno/neon-sprawl-server/quest-progress/` against a running server:
|
||||
|
||||
- **Get quest progress default** — all quests **`not_started`** unchanged.
|
||||
- **Post accept gather intro** — **`accepted: true`** unchanged.
|
||||
- **Post accept duplicate** — **`accepted: false`**, **`reasonCode: already_active`** unchanged.
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# NEO-122 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-122 |
|
||||
| Title | E7M1-11: Client quest progress + accept HUD (Godot) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-122/e7m1-11-client-quest-progress-accept-hud-godot |
|
||||
| Plan | `docs/plans/NEO-122-implementation-plan.md` |
|
||||
| Branch | `NEO-122-client-quest-progress-accept-hud` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Fresh dev player:** stop any running server, then start a new instance so in-memory quest progress resets.
|
||||
- **No Bruno/curl** for this checklist — accept and progress use Godot **Q** / **Shift+Q** and gameplay actions only.
|
||||
- NEO-119 quest-progress GET, NEO-120 accept POST, and NEO-115 quest-definitions GET landed on `main`.
|
||||
|
||||
## Expected quest HUD progression
|
||||
|
||||
| After | `QuestProgressLabel` | `QuestAcceptFeedbackLabel` |
|
||||
|-------|----------------------|----------------------------|
|
||||
| Boot | All four quests **`not started`** (display names when defs loaded) | **`Quest accept: — (Q gather / Shift+Q next)`** |
|
||||
| **Q** accept gather | **`Intro: Salvage Run: active step 1`** | **`Quest accept: Intro: Salvage Run accepted`** |
|
||||
| Gather scrap (**R** on resource node ×3) | Gather objective counter increases (e.g. **`gather_intro_obj_scrap=3`**) | unchanged |
|
||||
| Gather quest completes | **`Intro: Salvage Run: completed`** | unchanged |
|
||||
| **Shift+Q** after gather complete (catalog order) | **`Intro: Clear the Pocket: active`** — combat sorts before refine | **`Quest accept: Intro: Clear the Pocket accepted`** |
|
||||
| Craft **`refine_scrap_standard`** (Economy HUD) | Refine quest completes when recipe succeeds | unchanged |
|
||||
| Duplicate **Q** on gather | Gather row stays **`completed`** | **`Quest accept: denied — already_completed`** (or **`already_active`**) |
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`QuestProgressLabel`** shows four **`not started`** lines and **`QuestAcceptFeedbackLabel`** shows the idle hint (in **`HudRootScroll`**, below encounter loot labels). Economy panel pinned bottom-left (not off-screen).
|
||||
3. Press **Q**. Verify gather intro becomes **`active step 1`** and accept feedback shows success.
|
||||
4. Gather scrap until gather quest completes (resource node **R**). Verify counter updates on **`QuestProgressLabel`** after each successful gather (may lag one GET).
|
||||
5. Press **Shift+Q** before gather is complete — verify **`Quest accept: denied — prerequisite_incomplete`** for refine (or **`no eligible quest`** if refine is not yet eligible).
|
||||
6. After gather completes, press **Shift+Q**. Verify **combat** intro becomes **`active`** (catalog order — combat before refine). Component refine/craft path: complete combat first, then repeat **Shift+Q** for refine; see [NEO-123 capstone](NEO-123.md) for full four-quest order.
|
||||
7. Open **Economy HUD** recipe panel — **Refine Scrap (Standard)** is the **first** row (scroll starts at top). After combat intro is **`completed`** and refine intro is **`active`**, craft when **≥5** scrap in bag.
|
||||
8. Craft **`refine_scrap_standard`** with Economy HUD open. Verify refine quest **`completed`** after successful craft (requires refine intro **`active`** from step 6 follow-up **Shift+Q**).
|
||||
9. Press **Q** again on already-completed gather. Verify deny feedback on **`QuestAcceptFeedbackLabel`** without Bruno.
|
||||
10. Optional: stop server while Godot running, press **Q** — verify **`Quests: error — …`** or **`Quest accept: failed — …`** visible on HUD.
|
||||
11. Optional: block **`GET /game/world/quest-definitions`** only while progress GET succeeds — verify **`definitions error — …`** under **`QuestProgressLabel`** with raw quest ids as fallback.
|
||||
12. Toggle **Economy HUD** off/on — debug labels in **`HudRootScroll`** remain; recipe repopulate + scroll reset still works.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Shift+Q** uses quest-definitions **catalog order** (ordinal `id`): after gather intro completes, **combat** intro is offered before **refine** intro. Server deny is authoritative when prerequisites block accept.
|
||||
- Full four-quest chain capstone (supersedes partial intro path here): [NEO-123 manual QA](NEO-123.md).
|
||||
- Encounter progress HUD regression: [NEO-110 manual QA](NEO-110.md).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] Steps 1–9 completable in one session without Bruno/curl.
|
||||
- [ ] Quest status visible after **Q** accept and updates after gather/craft without manual refresh key.
|
||||
- [ ] Failed GET/accept shows visible HUD error copy.
|
||||
- [ ] Economy HUD craft panel visible for step 7–8 (recipes first row, no overlap with quest labels).
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# NEO-123 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-123 |
|
||||
| Title | E7M1-12: Playable onboarding quest chain capstone (Godot) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-123/e7m1-12-playable-onboarding-quest-chain-capstone-godot |
|
||||
| Plan | `docs/plans/NEO-123-implementation-plan.md` |
|
||||
| Branch | `NEO-123-playable-onboarding-quest-chain-capstone` |
|
||||
|
||||
## Preconditions
|
||||
|
||||
- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** quest progress, inventory, encounter progress, NPC HP, and resource-node depletion reset.
|
||||
- **No Bruno/curl** for this checklist — accept uses **Q** / **Shift+Q**; gameplay uses **R**, **Tab** + **1**, and Economy HUD **Craft** buttons only.
|
||||
- NEO-112–NEO-122 quest spine and NEO-75/NEO-111 economy/combat capstones landed on `main`.
|
||||
- **`Economy HUD`** toggle **on** for craft and inventory verification.
|
||||
|
||||
## Quest catalog (frozen)
|
||||
|
||||
| Quest id | Display name | Prerequisites | Objective |
|
||||
|----------|--------------|---------------|-----------|
|
||||
| `prototype_quest_gather_intro` | Intro: Salvage Run | none | gather **3** `scrap_metal_bulk` |
|
||||
| `prototype_quest_combat_intro` | Intro: Clear the Pocket | none | `encounter_complete` **`prototype_combat_pocket`** |
|
||||
| `prototype_quest_refine_intro` | Intro: Refine Stock | gather intro | craft **`refine_scrap_standard`** ×1 |
|
||||
| `prototype_quest_operator_chain` | Operator Chain | all three intros | gather ×5 → refine ×1 → **`make_field_stim_mk0`** ×1 → hold **`contract_handoff_token`** ×1 |
|
||||
|
||||
## Accept key contract
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| **Q** | Accept **`prototype_quest_gather_intro`** |
|
||||
| **Shift+Q** | First catalog-order **`not_started`** quest with all **`prerequisiteQuestIds`** **`completed`** |
|
||||
|
||||
**Catalog order (ordinal `id`):** combat → gather → operator chain → refine. After gather intro completes, **Shift+Q** accepts **combat** (not refine). After combat completes, **Shift+Q** accepts **refine**. After all three intros complete, **Shift+Q** accepts **operator chain**.
|
||||
|
||||
## Resource node anchors (gather)
|
||||
|
||||
| Node id | Anchor | Yield per **R** |
|
||||
|---------|--------|-----------------|
|
||||
| `prototype_resource_node_alpha` | **(12, −6)** | +1 scrap |
|
||||
| `prototype_subsurface_vein_beta` | **(18, −6)** | +2 scrap |
|
||||
| `prototype_bio_mat_gamma` | **(12, 0)** | +3 scrap |
|
||||
| `prototype_urban_bulk_delta` | **(18, 0)** | +5 scrap |
|
||||
|
||||
Nodes allow multiple gathers (`maxGathers` **10** each). Quest gather counters credit **scrap quantity granted**, not gather event count.
|
||||
|
||||
## Combat archetypes (defeat order flexible)
|
||||
|
||||
| Archetype | Instance id | Marker | Pulses to defeat |
|
||||
|-----------|-------------|--------|------------------|
|
||||
| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 4 |
|
||||
| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 4 |
|
||||
| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 8 |
|
||||
|
||||
Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown).
|
||||
|
||||
## Expected quest HUD progression
|
||||
|
||||
| Phase | `QuestProgressLabel` (high level) | `QuestAcceptFeedbackLabel` |
|
||||
|-------|-----------------------------------|----------------------------|
|
||||
| Boot | All four **`not started`** | **`Quest accept: — (Q gather / Shift+Q next)`** |
|
||||
| Gather intro | Salvage Run **`active step 1`** → **`completed`** | **`accepted`** on **Q** |
|
||||
| Combat intro | Clear the Pocket **`active`** → **`completed`** | **`accepted`** on **Shift+Q** |
|
||||
| Refine intro | Refine Stock **`active`** → **`completed`** | **`accepted`** on **Shift+Q** |
|
||||
| Operator chain | Operator Chain **`active step 1…4`** → **`completed`** | **`accepted`** on **Shift+Q** |
|
||||
| Idempotency | All four still **`completed`** | **Q** deny / **Shift+Q** no eligible |
|
||||
|
||||
## Material math (suggested path)
|
||||
|
||||
| Phase | Actions | Bag after (approx.) |
|
||||
|-------|---------|---------------------|
|
||||
| Gather intro | **Q**; **R** at **gamma** once (+3 scrap) | 3 scrap |
|
||||
| Combat intro | **Shift+Q**; defeat ×3 NPCs | 13 scrap, 1 token (+10 scrap loot) |
|
||||
| Refine intro | **Shift+Q**; craft **`refine_scrap_standard`** | 8 scrap, 1 plate, 1 token |
|
||||
| Chain step 1 | **Shift+Q**; **R** at **delta** once (+5 scrap) | 13 scrap; counter **5/5** |
|
||||
| Chain step 2 | Craft **`refine_scrap_standard`** | 8 scrap, 2 plates, 1 token |
|
||||
| Chain step 3 | Craft **`make_field_stim_mk0`** | 7 scrap, 0 plates, 1 stim, 1 token |
|
||||
| Chain step 4 | Token already held from combat loot | Operator Chain **`completed`** |
|
||||
|
||||
Adjust gathers/crafts if bag counts differ; counters and craft denies are authoritative.
|
||||
|
||||
## Checklist
|
||||
|
||||
### Boot
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`QuestProgressLabel`** lists four **`not started`** quests and **`QuestAcceptFeedbackLabel`** shows the idle hint (in **`HudRootScroll`**). **`Economy HUD`** on; economy panel pinned bottom-left.
|
||||
|
||||
### Intro: Salvage Run (gather)
|
||||
|
||||
3. Press **Q**. Verify **`Intro: Salvage Run: active step 1`** and accept success feedback.
|
||||
4. Walk to **gamma** **(12, 0)**; press **R** once. Verify counter **`chain_obj_gather`** is **not** present yet; gather intro counter increases (e.g. **`gather_intro_obj_scrap=3`**).
|
||||
5. Verify **`Intro: Salvage Run: completed`**.
|
||||
|
||||
### Intro: Clear the Pocket (combat)
|
||||
|
||||
6. Press **Shift+Q**. Verify **`Intro: Clear the Pocket: active`** (combat intro — catalog order picks combat before refine).
|
||||
7. Defeat all three prototype NPCs (**Tab** + **1**, any order). Verify **`EncounterProgressLabel`** **`completed (3/3)`** and **`EncounterCompleteLabel`** lists **`scrap_metal_bulk ×10`** + **`contract_handoff_token ×1`**.
|
||||
8. Verify **`Intro: Clear the Pocket: completed`** and **`InventoryLabel`** shows **`contract_handoff_token`** (**1**).
|
||||
|
||||
### Intro: Refine Stock (craft)
|
||||
|
||||
9. Press **Shift+Q**. Verify **`Intro: Refine Stock: active step 1`**.
|
||||
10. In Economy HUD, **Craft** **`refine_scrap_standard`** when **≥5** scrap in bag. Verify **`Intro: Refine Stock: completed`**.
|
||||
|
||||
### Operator Chain (four steps)
|
||||
|
||||
11. Press **Shift+Q**. Verify **`Operator Chain: active step 1`**.
|
||||
12. **Chain step 1 — gather ×5:** press **R** at resource nodes until step advances (suggested: **delta** once for +5 scrap). Verify **`active step 2`** on **`QuestProgressLabel`**.
|
||||
13. **Chain step 2 — refine:** craft **`refine_scrap_standard`**. Verify **`active step 3`**.
|
||||
14. **Chain step 3 — stim:** craft **`make_field_stim_mk0`** (needs **2** plates + **1** scrap). Verify **`active step 4`**.
|
||||
15. **Chain step 4 — token:** verify **`Operator Chain: completed`** without extra actions (token from step 8).
|
||||
|
||||
### Capstone snapshot
|
||||
|
||||
16. **Record** final **`QuestProgressLabel`**: all four quests **`completed`**.
|
||||
|
||||
### Idempotency (no duplicate quest completion)
|
||||
|
||||
17. **Godot restart:** stop Godot (**Shift+F5**); **F5** again **without** stopping the server. Verify all four quests still **`completed`**.
|
||||
18. Press **Q** on gather intro. Verify **`Quest accept: denied — already_completed`** (or **`already_active`**).
|
||||
19. Press **Shift+Q**. Verify **`Quest accept: no eligible quest`** (or equivalent deny). Quest HUD **unchanged** from step 16.
|
||||
|
||||
### Regression
|
||||
|
||||
20. Component quest HUD: [NEO-122 manual QA](NEO-122.md).
|
||||
21. Encounter loot + token: [NEO-111 manual QA](NEO-111.md).
|
||||
22. Economy craft panel: [NEO-75 manual QA](NEO-75.md).
|
||||
|
||||
## Notes
|
||||
|
||||
- **Shift+Q catalog order** is intentional for Slice 1 — combat intro before refine intro after gather completes. Full capstone supersedes [NEO-122](NEO-122.md) partial intro path.
|
||||
- Combat defeat order is flexible; only **0/3 → completed (3/3)** is asserted.
|
||||
- Chain terminal step is **`inventory_has_item`** — no token consume in Slice 1.
|
||||
- **`POST /game/__dev/combat-targets-fixture`** is **out of scope** — clears encounter progress unsuitable for idempotency ([server README — dev combat-target fixture](../../server/README.md#dev-combat-target-fixture-brunomanual-qa)).
|
||||
- Epic 7 Slice 1 AC: 3 onboarding + 1 chain complete without duped completion ([epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#epic-7-definition-of-done)).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] Steps 1–19 completable in one session without Bruno/curl.
|
||||
- [x] All four prototype quests reach **`completed`**; operator chain finishes when **`contract_handoff_token`** held.
|
||||
- [x] Godot restart + duplicate **Q**/**Shift+Q** leave quest statuses unchanged (no duplicate completion).
|
||||
- [x] Epic 7 Slice 1 AC satisfied in Godot.
|
||||
|
|
@ -16,8 +16,8 @@
|
|||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows after boot.
|
||||
3. Scroll the recipe panel below skills — **eight** prototype recipes with **Craft** buttons.
|
||||
4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged.
|
||||
3. Scroll the recipe panel in **Economy HUD** (`BodyScroll/Body/CraftRecipePanel`) — **eight** prototype recipes with **Craft** buttons. **Refine Scrap (Standard)** is the first row (NEO-122 layout).
|
||||
4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** (in **`HudRootScroll`**) shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged.
|
||||
5. Gather **≥5** **`scrap_metal_bulk`** (see [NEO-73 manual QA](NEO-73.md) **R** at resource nodes).
|
||||
6. Click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: +1 …`** (refined plate output). **`InventoryLabel`** updates without **I**. **`refine`** xp increases on **`SkillProgressionLabel`**.
|
||||
7. Gather **≥1** more scrap if needed; ensure **≥2** **`refined_plate_stock`** and **≥1** **`scrap_metal_bulk`** in bag (craft **`refine_scrap_standard`** again or gather).
|
||||
|
|
@ -28,3 +28,4 @@
|
|||
|
||||
- Craft quantity is fixed at **1** per button (no spinner in this story).
|
||||
- Success copy comes from POST **`outputsGranted`**; inventory and skill rows refresh via GET after success only.
|
||||
- **NEO-122 layout:** debug feedback labels live in **`HudRootScroll`**; economy block (recipes, inventory, skills) is pinned bottom-left — scroll economy **`BodyScroll`** for inventory/skills below recipes.
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@
|
|||
## Checklist
|
||||
|
||||
1. Start server: `cd server/NeonSprawl.Server && dotnet run`.
|
||||
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`InventoryLabel`** shows empty bag; **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows.
|
||||
2. Run Godot main scene (**F5**). Confirm **`Economy HUD`** toggle is **on**; **`InventoryLabel`** shows empty bag; **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows. Scroll **`BodyScroll`** if inventory/skills are below the recipe list (NEO-122 recipes-first order).
|
||||
3. **WASD** to **`prototype_resource_node_alpha`** anchor **(12, −6)**. Press **R** once. **`GatherFeedbackLabel`** shows **`+1`** scrap delta; inventory scrap updates without **I**; **`salvage`** xp increases.
|
||||
4. Walk to **`prototype_subsurface_vein_beta`** **(18, −6)**; press **R** (**+2** scrap). Repeat at **`prototype_bio_mat_gamma`** **(12, 0)** (**+3**) and **`prototype_urban_bulk_delta`** **(18, 0)** (**+5**). Inventory shows **11** scrap total.
|
||||
5. In the recipe panel, click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows success; inventory shows **6** scrap and **1** **`refined_plate_stock`**; **`refine`** xp increases.
|
||||
6. Click **Craft** on **`refine_scrap_standard`** again. Inventory shows **1** scrap and **2** plates.
|
||||
7. Click **Craft** on **`make_field_stim_mk0`**. Success feedback shows **`field_stim_mk0`** output; **`InventoryLabel`** lists **`Field Stim Mk0`** (or agreed display name) with quantity **≥ 1**; **`refine`** xp increases again.
|
||||
8. Uncheck **`Economy HUD`** — inventory, skills, and recipe panel hide; gather/craft feedback labels above remain visible. Check **`Economy HUD`** again — panel repopulates with current data.
|
||||
8. Uncheck **`Economy HUD`** — inventory, skills, and recipe panel hide; gather/craft feedback labels in **`HudRootScroll`** remain visible. Check **`Economy HUD`** again — panel repopulates with current data.
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
| 11 | **E7M1-11** | client | E7M1-08, E7M1-09 |
|
||||
| 12 | **E7M1-12** | client | E7M1-11 |
|
||||
|
||||
**Downstream (separate modules):** [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) idempotent **`QuestRewardBundle`** apply + consume **`EncounterCompleteEvent`**; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) faction ledger (pre-production); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
**Downstream (separate modules):** [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) Slice 2 backlog [E7M2-prototype-backlog.md](E7M2-prototype-backlog.md) ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)–[NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)); [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) faction ledger (pre-production); [E8.M1](../decomposition/modules/E8_M1_PartyAndMatchAssembly.md) party-credit quests.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -243,10 +243,10 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Active **`prototype_quest_gather_intro`** advances when **`scrap_metal_bulk`** gather succeeds.
|
||||
- [ ] Active **`prototype_quest_refine_intro`** advances on **`refine_scrap_standard`** craft success.
|
||||
- [ ] Active **`prototype_quest_combat_intro`** advances when **`prototype_combat_pocket`** completes.
|
||||
- [ ] Chain quest steps advance in order across mixed objective kinds.
|
||||
- [x] Active **`prototype_quest_gather_intro`** advances when **`scrap_metal_bulk`** gather succeeds.
|
||||
- [x] Active **`prototype_quest_refine_intro`** advances on **`refine_scrap_standard`** craft success.
|
||||
- [x] Active **`prototype_quest_combat_intro`** advances when **`prototype_combat_pocket`** completes.
|
||||
- [x] Chain quest steps advance in order across mixed objective kinds.
|
||||
|
||||
**Client counterpart:** progress visible via [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) GET HUD after mutations.
|
||||
|
||||
|
|
@ -269,8 +269,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET returns stable v1 envelope for **`dev-local-1`** player.
|
||||
- [ ] Completed quests show **`completed`** with step count at terminal step.
|
||||
- [x] GET returns stable v1 envelope for **`dev-local-1`** player.
|
||||
- [x] Completed quests show **`completed`** with step count at terminal step.
|
||||
|
||||
**Client counterpart:** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — **`quest_progress_client.gd`** + HUD labels.
|
||||
|
||||
|
|
@ -292,8 +292,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Successful accept transitions row **`not_started` → `active`**.
|
||||
- [ ] Deny returns structured **`reasonCode`** without mutation.
|
||||
- [x] Successful accept transitions row **`not_started` → `active`**.
|
||||
- [x] Deny returns structured **`reasonCode`** without mutation.
|
||||
|
||||
**Client counterpart:** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — accept binding + feedback label.
|
||||
|
||||
|
|
@ -315,8 +315,10 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook sites exist at accept, step complete, quest complete paths.
|
||||
- [ ] Epic 7 Slice 1 telemetry vocabulary documented.
|
||||
- [x] Hook sites exist at accept, step complete, quest complete paths.
|
||||
- [x] Epic 7 Slice 1 telemetry vocabulary documented.
|
||||
|
||||
**Landed (NEO-121):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** in **`QuestStateOperations`**; [server README — Quest telemetry hooks (NEO-121)](../../server/README.md#quest-telemetry-hooks-neo-121); plan [NEO-121](../../plans/NEO-121-implementation-plan.md).
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
|
|
@ -342,11 +344,11 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player sees quest status change after accept without Bruno.
|
||||
- [ ] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [ ] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
- [x] Player sees quest status change after accept without Bruno.
|
||||
- [x] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [x] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
|
||||
**Client counterpart:** this story **is** the client counterpart for E7M1-08/09.
|
||||
**Landed (NEO-122):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; **Q** / **Shift+Q** accept; boot + event-driven GET refresh; GdUnit + [`docs/manual-qa/NEO-122.md`](../manual-qa/NEO-122.md); plan [NEO-122](../plans/NEO-122-implementation-plan.md); [client README — Quest progress + accept HUD (NEO-122)](../../client/README.md#quest-progress--accept-hud-neo-122).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -366,9 +368,11 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes **`docs/manual-qa/NEO-123.md`** with server + client.
|
||||
- [ ] Epic 7 Slice 1 AC: 3 onboarding + 1 chain complete without duplicate completion on replay.
|
||||
- [ ] Re-read [epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#definition-of-done) for prototype minimums.
|
||||
- [x] Human completes **`docs/manual-qa/NEO-123.md`** with server + client.
|
||||
- [x] Epic 7 Slice 1 AC: 3 onboarding + 1 chain complete without duplicate completion on replay.
|
||||
- [x] Re-read [epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#definition-of-done) for prototype minimums.
|
||||
|
||||
**Landed (NEO-123):** [`docs/manual-qa/NEO-123.md`](../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); E7.M1 **Ready**; Epic 7 Slice 1 client capstone complete.
|
||||
|
||||
**Client counterpart:** this story **is** the Slice 1 client capstone.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
# E7.M2 — Prototype story backlog (RewardAndUnlockRouter)
|
||||
|
||||
Working backlog for **Epic 7 — Slice 2** ([reward and unlock routing](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing)). Decomposition and contracts: [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md).
|
||||
|
||||
**Full-stack policy:** Epics deliver **playable game features**. Every **player-visible** story has a **`client`** Linear issue created in **this same decomposition pass** — not an undocumented follow-up. Reference: [E7M1 (paired server+client)](../plans/E7M1-prototype-backlog.md), [E5M3 (encounter loot capstone)](../plans/E5M3-prototype-backlog.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E7.M2`**; add **`server`** or **`client`** + **`Story`** as listed.
|
||||
|
||||
**Precursor (do not re-scope):** [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) Slice 1 **Ready** — quest state + objectives only; no duplicate completion grants on replay ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)). [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) encounter loot stays on **`EncounterCompletionOperations`** + **`rewardGrantSummary`** on encounter-progress GET — **not** migrated through E7.M2 in Slice 2. **`EncounterCompleteEvent`** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)) remains a future consumer hook. **NEO-43** [`MissionRewardSkillXpGrant`](../../server/NeonSprawl.Server/Game/Skills/MissionRewardSkillXpGrant.cs) is the skill-XP apply path for bundle rows with **`sourceKind: mission_reward`**.
|
||||
|
||||
**Upstream (must be landed):** [E7.M1](E7_M1_QuestStateMachine.md) **Ready** ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)–[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)); [E2.M2](E2_M2_XpAwardAndLevelEngine.md) grant stack ([NEO-38](https://linear.app/neon-sprawl/issue/NEO-38), [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43)); [E3.M3](E3_M3_ItemizationAndInventorySchema.md) inventory ([NEO-54](https://linear.app/neon-sprawl/issue/NEO-54)–[NEO-55](https://linear.app/neon-sprawl/issue/NEO-55)). [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) quest HUD for client reward surfacing.
|
||||
|
||||
**Prototype reward spine (frozen in E7M2-01):** **`completionRewardBundle`** on each of the **four** frozen [E7.M1 quests](E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01) — **quest completion only** (not per-step). Grant kinds v1: **`itemGrants`** + **`skillXpGrants`** only. **Solo only** — per-character idempotent keys ([quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md)).
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the story branch when work starts.
|
||||
|
||||
| Slug | Layer | Linear |
|
||||
|------|-------|--------|
|
||||
| E7M2-01 | server | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) |
|
||||
| E7M2-02 | server | [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) |
|
||||
| E7M2-03 | server | [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) |
|
||||
| E7M2-04 | server | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) |
|
||||
| E7M2-05 | server | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) |
|
||||
| E7M2-06 | server | [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) |
|
||||
| E7M2-07 | server | [NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) |
|
||||
| E7M2-08 | client | [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) |
|
||||
| E7M2-09 | client | [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) |
|
||||
|
||||
**Dependency graph in Linear:** E7M2-02 blocked by E7M2-01. E7M2-03 blocked by E7M2-01. E7M2-04 blocked by E7M2-02 and E7M2-03. E7M2-05 blocked by E7M2-04. E7M2-06 blocked by E7M2-05. E7M2-07 blocked by E7M2-05 (may parallel E7M2-06). E7M2-08 blocked by E7M2-06. E7M2-09 blocked by E7M2-08.
|
||||
|
||||
**Board order:** estimates **1–9** matching slug order (E7M2-01 = 1 … E7M2-09 = 9). On the Epic 7 project board, sort by **Estimate** (ascending).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Layer | Depends on |
|
||||
|-------|------|-------|------------|
|
||||
| 1 | **E7M2-01** | server | E7.M1 quest catalog frozen |
|
||||
| 2 | **E7M2-02** | server | E7M2-01 |
|
||||
| 3 | **E7M2-03** | server | E7M2-01 |
|
||||
| 4 | **E7M2-04** | server | E7M2-02, E7M2-03 |
|
||||
| 5 | **E7M2-05** | server | E7M2-04 |
|
||||
| 6 | **E7M2-06** | server | E7M2-05 |
|
||||
| 7 | **E7M2-07** | server | E7M2-05 |
|
||||
| 8 | **E7M2-08** | client | E7M2-06 |
|
||||
| 9 | **E7M2-09** | client | E7M2-08 |
|
||||
|
||||
**Downstream (separate modules):** [E6.M4](../decomposition/modules/E6_M4_RewardParityEnforcer.md) lint against bundles; [E7.M3](../decomposition/modules/E7_M3_FactionReputationLedger.md) reputation grants (pre-production); E5.M3 **`EncounterCompleteEvent`** full router consumer (optional follow-on).
|
||||
|
||||
---
|
||||
|
||||
## Kickoff decisions (decomposition defaults)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|-------|----------|-----------|
|
||||
| Reward trigger | **Quest completion only** (not per-step) | Slice 2 AC is idempotent **completion** delivery; step bundles add scope without prototype need |
|
||||
| Grant kinds (v1) | **`itemGrants`** + **`skillXpGrants`** (`mission_reward` at apply) | [NEO-43](NEO-43-implementation-plan.md) prep; unlock/flag grants deferred |
|
||||
| Unlock / world gates | **Deferred** to pre-production | Epic mentions unlocks; no prototype gate content yet — comment-only **`unlock_granted`** hook |
|
||||
| Encounter loot | **Unchanged** — E5.M3 direct grants | Avoid breaking [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); `EncounterCompleteEvent` consumer is follow-on |
|
||||
| Bundle attachment | **`completionRewardBundle`** on each frozen **four** quests | Readable capstone; CI enforces shape on all E7.M1 ids |
|
||||
| Idempotency key | **`{playerId}:quest_complete:{questId}`** | [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md); once per character per quest |
|
||||
| Delivery store | **In-memory** **`IRewardDeliveryStore`** (+ optional Postgres migration) | Matches encounter/quest prototype stores |
|
||||
| Fail-closed | **Quest complete rolls back** if bundle apply fails after mark (inventory full deny) | Mirror [NEO-105](NEO-105-implementation-plan.md) encounter completion compensating rollback |
|
||||
| HTTP read | **Extend** **`GET …/quest-progress`** with **`completionRewardSummary`** per completed quest | Mirror encounter **`rewardGrantSummary`** ([NEO-108](NEO-108-implementation-plan.md)); no new top-level route in Slice 2 |
|
||||
| Party mode | **Solo only** | E8.M1 not landed |
|
||||
| HUD fidelity | Prototype labels / debug panels | NEO-122 / NEO-110 precedent |
|
||||
|
||||
### Prototype completion bundles (E7M2-01 freeze — tunable display only)
|
||||
|
||||
| Quest id | Item grants | Skill XP grants |
|
||||
|----------|-------------|-----------------|
|
||||
| `prototype_quest_gather_intro` | — | **`salvage`** **25** (`mission_reward`) |
|
||||
| `prototype_quest_refine_intro` | — | **`refine`** **25** (`mission_reward`) |
|
||||
| `prototype_quest_combat_intro` | — | **`salvage`** **25** (`mission_reward`) |
|
||||
| `prototype_quest_operator_chain` | **`survey_drone_kit` ×1** | **`salvage`** **50** (`mission_reward`) |
|
||||
|
||||
Combat intro bundle is **skill XP only** — encounter loot already granted **`contract_handoff_token`** via E5.M3 (no double item payout on same milestone).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-01 — QuestRewardBundle schema + quest catalog extension + CI
|
||||
|
||||
**Goal:** Lock **`completionRewardBundle`** content shape and CI validation on the four frozen quests before server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/quest-reward-bundle.schema.json`, `content/schemas/quest-skill-xp-grant.schema.json` (reuse or align with existing grant row patterns).
|
||||
- Extend `content/schemas/quest-def.schema.json` with optional **`completionRewardBundle`**.
|
||||
- Update `content/quests/prototype_quests.json` — bundles per freeze table above.
|
||||
- `scripts/validate_content.py`: bundle schema validation, item id cross-refs, skill id cross-refs, `mission_reward` allowlist alignment with `prototype_skills.json`.
|
||||
- Designer note in [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) + `content/README.md`.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, delivery store, router, HTTP, Godot.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates quest JSON including optional completion bundles.
|
||||
- [x] All four prototype quests include a **`completionRewardBundle`** row matching freeze table.
|
||||
- [x] Invalid item/skill refs fail CI.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-02 — Server quest catalog load: reward bundle validation
|
||||
|
||||
**Goal:** Fail-fast startup validation of **`completionRewardBundle`** with CI parity.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend `server/NeonSprawl.Server/Game/Quests/` catalog loader / registry types.
|
||||
- Cross-check bundle grants against item + skill registries at startup.
|
||||
- Unit tests (AAA) for happy path and broken bundle refs.
|
||||
- `server/README.md` section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Delivery apply, HTTP projection changes.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Host exits on invalid bundle refs at startup.
|
||||
- [x] `dotnet test` covers loader gates.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-03 — Reward delivery store (`IRewardDeliveryStore`)
|
||||
|
||||
**Goal:** Durable idempotency record for **`RewardDeliveryEvent`** per player + quest completion.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `IRewardDeliveryStore`, in-memory implementation, optional Postgres migration (NEO-29-style split).
|
||||
- Idempotency key **`{playerId}:quest_complete:{questId}`**; store granted item + skill XP summary for audit/read projection.
|
||||
- Unit tests: first delivery applies; replay returns existing event without double-apply.
|
||||
- `server/README.md` store section.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Router apply logic (E7M2-04), quest-state wiring (E7M2-05).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] `TryRecord` idempotent on same key.
|
||||
- [x] Store readable summary for HTTP projection.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-04 — `RewardRouterOperations` (apply `QuestRewardBundle`)
|
||||
|
||||
**Goal:** Server-internal apply for item + skill XP completion bundles with structured outcomes.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `RewardRouterOperations.TryDeliverQuestCompletion` in `server/NeonSprawl.Server/Game/Quests/` (or `Game/Rewards/`).
|
||||
- Item grants via **`PlayerInventoryOperations`**; skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind (deny envelopes; not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper).
|
||||
- Deny reason codes (inventory full, unknown item/skill, xp deny).
|
||||
- Unit + integration tests (AAA): happy path, inventory deny, idempotent replay via store.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Quest state transition hooks (E7M2-05), HTTP DTOs (E7M2-06).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record.
|
||||
- [x] Skill XP uses **`mission_reward`** source kind only.
|
||||
|
||||
**Client counterpart:** none (server engine).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-05 — Wire router into `QuestStateOperations.TryMarkComplete`
|
||||
|
||||
**Goal:** First-time quest completion triggers bundle delivery exactly once.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Call **`RewardRouterOperations`** from **`TryMarkComplete`** success path (after state commit or transactional equivalent per fail-closed decision).
|
||||
- Skip delivery on idempotent complete replay (already `Completed` status).
|
||||
- Integration tests: complete gather intro → inventory/skills reflect bundle; replay → no duplicate grants.
|
||||
- Bruno smoke in `bruno/neon-sprawl-server/quest-progress/` (optional subfolder).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Encounter completion path; step-complete bundles.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Completing a quest via objective wiring delivers bundle once.
|
||||
- [ ] Idempotent complete does not double-grant items or XP.
|
||||
|
||||
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131) — HUD surfacing (blocked by E7M2-06).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-06 — Extend `GET …/quest-progress` with `completionRewardSummary`
|
||||
|
||||
**Goal:** Client-readable grant summary on completed quest rows (mirror encounter progress).
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend `QuestProgressApi` DTOs + OpenAPI-stable JSON: per completed quest optional **`completionRewardSummary`** (item lines + skill XP lines).
|
||||
- Populate from **`IRewardDeliveryStore`** / delivery event snapshot.
|
||||
- Integration tests + Bruno assertions on gather intro complete path.
|
||||
- `server/README.md` quest-progress section update.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (E7M2-08).
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Completed quest rows include summary when delivery recorded.
|
||||
- [ ] `not_started` / `active` rows omit summary.
|
||||
|
||||
**Client counterpart:** [NEO-131](https://linear.app/neon-sprawl/issue/NEO-131).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-07 — Reward telemetry hook sites (comment-only)
|
||||
|
||||
**Goal:** Anchor future E9.M1 catalog events without ingest.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Comment-only **`reward_delivery`** in **`RewardRouterOperations`** (first-time success).
|
||||
- Comment-only **`unlock_granted`** stub (no runtime unlock apply in Slice 2).
|
||||
- `server/README.md` hook list.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- E9.M1 ingest, unlock engine.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook sites documented in README; no behavior change beyond comments.
|
||||
|
||||
**Client counterpart:** none (infrastructure-only).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-08 — Client quest completion reward HUD (Godot)
|
||||
|
||||
**Goal:** Player sees quest completion rewards in Godot without Bruno.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Extend **`quest_progress_client.gd`** parse for **`completionRewardSummary`**.
|
||||
- **`QuestRewardDeliveryLabel`** (or extend **`QuestProgressLabel`** / **`quest_hud_controller.gd`**) — compact grant lines when quest transitions to **`completed`**.
|
||||
- Refresh on quest-progress GET after objective events (reuse NEO-122 triggers).
|
||||
- GdUnit parse tests; `client/README.md` section.
|
||||
- `docs/manual-qa/NEO-131.md` component checklist.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Final quest journal UI; reward VFX.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Completing a quest shows readable reward copy on HUD.
|
||||
- [ ] Failed GET surfaces visible error (NEO-122 pattern).
|
||||
|
||||
**Server counterpart:** [NEO-129](https://linear.app/neon-sprawl/issue/NEO-129).
|
||||
|
||||
---
|
||||
|
||||
### E7M2-09 — Playable quest reward delivery capstone (Godot)
|
||||
|
||||
**Goal:** Prove Epic 7 Slice 2 acceptance **in Godot**: quest completion grants items + skill XP **once**; idempotent on replay.
|
||||
|
||||
**In scope**
|
||||
|
||||
- **`docs/manual-qa/NEO-132.md`**: single-session capstone — extend [NEO-123](NEO-123.md) flow or shorter reward-focused path; verify **`completionRewardSummary`** + inventory/skills; Godot restart + duplicate complete attempt unchanged.
|
||||
- **`client/README.md`**: **End-to-end quest reward loop** section; cross-links NEO-124–NEO-131.
|
||||
- Module alignment on completion: [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [module_dependency_register.md](../decomposition/modules/module_dependency_register.md).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Encounter loot migration; faction reputation; Bruno-only proof.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Human completes **`docs/manual-qa/NEO-132.md`** with server + client.
|
||||
- [ ] Epic 7 Slice 2 AC: idempotent reward delivery; replays cannot double-claim.
|
||||
- [ ] Re-read [epic_07 Slice 2 AC](../decomposition/epics/epic_07_quest_faction.md#slice-2---reward-and-unlock-routing).
|
||||
|
||||
**Client counterpart:** this story **is** the Slice 2 client capstone.
|
||||
|
||||
---
|
||||
|
||||
## Decomposition complete checklist
|
||||
|
||||
- [x] Every player-visible AC has a **`client`** Linear issue (NEO-131, NEO-132)
|
||||
- [x] No forbidden deferral language in backlog
|
||||
- [x] Capstone client story exists (E7M2-09 / NEO-132)
|
||||
- [x] Epic DoD updated for Godot verification (see epic_07 update)
|
||||
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M2 row updated (decomposition pass)
|
||||
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M2 note updated
|
||||
|
||||
## Related docs
|
||||
|
||||
- [epic_07_quest_faction.md](../decomposition/epics/epic_07_quest_faction.md)
|
||||
- [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md)
|
||||
- [NEO-43 implementation plan](NEO-43-implementation-plan.md)
|
||||
- [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md)
|
||||
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
# NEO-118 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-118 |
|
||||
| **Title** | E7M1-07: Quest objective wiring (gather, craft, encounter) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-118/e7m1-07-quest-objective-wiring-gather-craft-encounter |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-07** |
|
||||
| **Branch** | `NEO-118-quest-objective-wiring` |
|
||||
| **Precursor** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations` accept/advance/complete (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-106](NEO-106-implementation-plan.md) — `EncounterCombatWiring` best-effort side effect on success paths; [NEO-117](NEO-117-implementation-plan.md) — `QuestStateOperations` for advance/complete |
|
||||
| **Blocks** | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — GET quest-progress; [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) — telemetry hook sites |
|
||||
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — quest progress HUD reads GET snapshot after gather/craft/encounter mutations (E7M1-11). Bruno-only progress verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **`inventory_has_item` triggers** | When to evaluate beyond gather/craft/encounter grant paths? | Inventory mutations **+ quest accept + after step advance** — backlog “post-grant or on polling hook from accept”; chain step 4 token often pre-held from combat intro | **Adopted** — user confirmed |
|
||||
| **Accept / step-advance re-eval scope** | Which objective kinds to reconcile on accept/step entry? | **`inventory_has_item` only**; `gather_item` / `craft_recipe` / `encounter_complete` stay event-driven | **Adopted** — user confirmed |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / NEO-116):**
|
||||
|
||||
- Counter accumulation uses **event quantity** (gather yield qty, craft batch qty); cap stored counter at objective `quantity` for satisfaction.
|
||||
- **All active quests** matching the event are credited (e.g. gather intro + chain step 1 on same gather).
|
||||
- Wiring is **best-effort** — quest evaluation failures must not fail gather/craft/encounter (mirror `EncounterCombatWiring`).
|
||||
- `encounter_complete` objectives treat satisfaction as counter **≥ 1** (no quantity field in catalog).
|
||||
- Step completion: when **all** objectives on the current step are satisfied → `TryAdvanceStep` to next index, or `TryMarkComplete` on terminal step; after advance, run **`inventory_has_item`** snapshot pass for the new step.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Advance active quest steps when server-side gather, craft, and encounter events succeed; evaluate **`inventory_has_item`** from inventory snapshots on grant paths, quest accept, and step advance.
|
||||
|
||||
**In scope (from Linear + [E7M1-07](E7M1-prototype-backlog.md#e7m1-07--quest-objective-wiring-gather-craft-encounter)):**
|
||||
|
||||
- **`QuestObjectiveWiring`** (static) — evaluate active quests against incoming events; update counters via store; auto advance/complete via **`QuestStateOperations`**.
|
||||
- Hook from **`GatherOperations.TryGather`** success — **`gather_item`** objectives (item id + quantity delta).
|
||||
- Hook from **`CraftOperations.TryCraft`** success — **`craft_recipe`** objectives (recipe id; quantity = craft batch size).
|
||||
- Hook from **`EncounterCompletionOperations.TryCompleteAndGrant`** success — **`encounter_complete`** objectives (encounter id).
|
||||
- **`inventory_has_item`** — inventory snapshot read after successful inventory mutations; **`TryProcessInventoryHasItem`** on quest accept and after step advance (current/new step only).
|
||||
- Counter accumulation for quantity objectives; auto **`TryAdvanceStep`** / **`TryMarkComplete`** when step objectives satisfied.
|
||||
- Integration-style tests (AAA): accept quest → simulate gather/craft/encounter → progress row advances through chain.
|
||||
- `server/README.md` — objective wiring subsection + hook table.
|
||||
- `docs/manual-qa/NEO-118.md` — Bruno/server-side verification checklist (no HTTP quest-progress yet).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Client-side objective tracking; item consume on turn-in; E7.M2 reward bundles.
|
||||
- HTTP **`GET …/quest-progress`** ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)) and accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)).
|
||||
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) / E7M1-10).
|
||||
- Retroactive **`encounter_complete`** credit on accept (encounter already done before quest accept).
|
||||
- GET polling for **`inventory_has_item`** (deferred to NEO-119 if needed).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Active **`prototype_quest_gather_intro`** advances when **`scrap_metal_bulk`** gather succeeds.
|
||||
- [x] Active **`prototype_quest_refine_intro`** advances on **`refine_scrap_standard`** craft success.
|
||||
- [x] Active **`prototype_quest_combat_intro`** advances when **`prototype_combat_pocket`** completes.
|
||||
- [x] Chain quest steps advance in order across mixed objective kinds.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Types:** `QuestObjectiveKinds`, `QuestObjectiveWiring` (`TryProcessGatherSuccess`, `TryProcessCraftSuccess`, `TryProcessEncounterCompleteSuccess`, `TryProcessInventoryHasItemForQuest`, `TryCompleteStepIfSatisfied`).
|
||||
- **Hooks:** `GatherOperations`, `CraftOperations`, `EncounterCompletionOperations`, `QuestStateOperations.TryAccept` / `TryAdvanceStep`.
|
||||
- **Tests:** `QuestObjectiveWiringTests` (6 AAA cases); existing gather/craft/encounter/state tests updated for new parameters.
|
||||
- **Docs:** `server/README.md` objective wiring section; `docs/manual-qa/NEO-118.md`.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`QuestObjectiveKinds`** — string constants: `gather_item`, `craft_recipe`, `encounter_complete`, `inventory_has_item` (mirror schema enum).
|
||||
|
||||
2. **`QuestObjectiveWiring`** (`Game/Quests/`)
|
||||
- **`TryProcessGatherSuccess(playerId, itemId, quantityGranted, …)`** — scan all catalog quest ids; for each **active** row, match current-step **`gather_item`** objectives by `itemId`; bump counter by `quantityGranted` (capped at objective `quantity`).
|
||||
- **`TryProcessCraftSuccess(playerId, recipeId, batchQuantity, …)`** — match **`craft_recipe`** by `recipeId`; increment counter by `batchQuantity` (capped).
|
||||
- **`TryProcessEncounterCompleteSuccess(playerId, encounterId, …)`** — match **`encounter_complete`**; set counter to **1** when encounter id matches.
|
||||
- **`TryProcessInventoryHasItemForPlayer(playerId, …)`** / **`TryProcessInventoryHasItemForQuest(playerId, questId, …)`** — read **`IPlayerInventoryStore.TryGetSnapshot`**; for current-step **`inventory_has_item`** objectives, set counter to `min(bagQuantity, objective.quantity)`; used after grants, on accept, after step advance.
|
||||
- Shared private **`TryEvaluateActiveQuests`** / **`TryCompleteStepIfSatisfied`**: when every objective on the current step meets threshold → if last step → **`QuestStateOperations.TryMarkComplete`**; else → **`TryAdvanceStep(currentIndex + 1)`** then **`TryProcessInventoryHasItemForQuest`** for the advanced quest (chain terminal token case).
|
||||
- Best-effort: swallow/log at debug; never throw to callers.
|
||||
|
||||
3. **Inventory quantity helper**
|
||||
- Private static on wiring (or small internal helper): sum **`PlayerInventorySnapshot`** bag slot quantities for an `itemId` (same semantics as craft/encounter tests).
|
||||
|
||||
4. **Call sites**
|
||||
- **`GatherOperations.TryGather`** — before successful return, call **`TryProcessGatherSuccess`** with `yield.ItemId` / `yield.Quantity` (add quest + inventory deps to method signature).
|
||||
- **`CraftOperations.TryCraft`** — before successful return, call **`TryProcessCraftSuccess`** with normalized recipe id and batch `quantity`.
|
||||
- **`EncounterCompletionOperations.TryCompleteAndGrant`** — after successful grant + completion mark, call **`TryProcessEncounterCompleteSuccess`** with normalized encounter id.
|
||||
- **`QuestStateOperations.TryAccept`** — after successful activation, call **`TryProcessInventoryHasItemForQuest`** (add `IPlayerInventoryStore` + `TimeProvider` params).
|
||||
- **`QuestStateOperations.TryAdvanceStep`** — after successful advance, call **`TryProcessInventoryHasItemForQuest`** for the same quest id.
|
||||
- Propagate new dependencies through **`InteractionApi`**, **`PlayerCraftApi`**, **`EncounterCombatWiring`**, and **`AbilityCastApi`** DI resolves (factory tests already resolve full host).
|
||||
|
||||
5. **Tests** (`QuestObjectiveWiringTests.cs`)
|
||||
- Use **`InMemoryWebApplicationFactory`** + real catalog/registry/store.
|
||||
- **Gather intro:** accept → N gathers until counter ≥ 3 → quest **completed** (single-step).
|
||||
- **Refine intro:** prerequisite setup → accept → craft **`refine_scrap_standard`** → **completed**.
|
||||
- **Combat intro:** accept → **`TryProcessEncounterCompleteSuccess`** for **`prototype_combat_pocket`** → **completed**.
|
||||
- **Chain:** accept chain → gather 5 scrap → advance to step 1 → craft refine → step 2 → craft stim → step 3 → seed **`contract_handoff_token`** in inventory → advance triggers step 4 **`inventory_has_item`** → **completed**.
|
||||
- **Parallel credit:** gather intro + chain step 0 both active; one gather credits both counters appropriately.
|
||||
- Update **`QuestStateOperationsTests`** signatures if accept/advance gain inventory/time params (pass real store from factory).
|
||||
|
||||
6. **Docs**
|
||||
- `server/README.md` — replace “Objective wiring is E7M1-07” stub with wiring summary, hook table, **`inventory_has_item`** trigger list.
|
||||
- Alignment register / module doc / E7M1 backlog checkboxes on story land.
|
||||
|
||||
### Wiring flow (gather success)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
G[GatherOperations.TryGather success] --> W[QuestObjectiveWiring.TryProcessGatherSuccess]
|
||||
W --> A{For each active quest}
|
||||
A --> M{Current step has matching gather_item?}
|
||||
M -->|yes| C[TryUpdateObjectiveCounter capped]
|
||||
M -->|no| A
|
||||
C --> S{All objectives satisfied?}
|
||||
S -->|terminal step| MC[QuestStateOperations.TryMarkComplete]
|
||||
S -->|more steps| AS[QuestStateOperations.TryAdvanceStep]
|
||||
AS --> I[TryProcessInventoryHasItemForQuest]
|
||||
S -->|no| A
|
||||
```
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveKinds.cs` | Stable objective kind string constants. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Event + inventory snapshot evaluation; step auto-advance/complete. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestObjectiveWiringTests.cs` | AAA integration-style tests for all four prototype quests + chain. |
|
||||
| `docs/manual-qa/NEO-118.md` | Server-side Bruno/unit verification checklist until NEO-119 HTTP. |
|
||||
| `docs/plans/NEO-118-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/GatherOperations.cs` | Call quest wiring on successful gather; add quest/inventory/time deps. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/CraftOperations.cs` | Call quest wiring on successful craft. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Call quest wiring after successful completion + grants. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | Invoke `inventory_has_item` pass after accept/advance; add inventory + time deps. |
|
||||
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Pass quest wiring deps into `GatherOperations.TryGather`. |
|
||||
| `server/NeonSprawl.Server/Game/Crafting/PlayerCraftApi.cs` | Pass quest wiring deps into `CraftOperations.TryCraft`. |
|
||||
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass quest wiring deps through to `EncounterCompletionOperations`. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Pass quest wiring deps into `EncounterCombatWiring` (if signature changes). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestStateOperationsTests.cs` | Update `TryAccept` / `TryAdvanceStep` call sites for new parameters. |
|
||||
| `server/README.md` | Document objective wiring hooks and `inventory_has_item` triggers. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-118 wiring landed (on story complete). |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | NEO-118 wiring bullet + README link (on story complete). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-118 (on story complete). |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-07 acceptance checkboxes (on story complete). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `QuestObjectiveWiringTests.cs` | **Gather intro:** accept → gather **`scrap_metal_bulk`** until complete. **Refine intro:** prerequisites met → accept → craft **`refine_scrap_standard`** → complete. **Combat intro:** accept → encounter complete event → complete. **Chain:** four-step mixed kinds including **`inventory_has_item`** terminal after token present. **Parallel:** gather intro + chain step 0 simultaneous credit. **Inventory on advance:** token pre-seeded → step advance to terminal → auto-complete without new grant. |
|
||||
| `QuestStateOperationsTests.cs` | Regression — existing accept/advance/complete cases with updated method signatures (inventory store + time provider from factory). |
|
||||
| Existing gather/craft/encounter tests | Smoke regression — no behavior change when no active quests (full suite in CI). |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **`QuestStateOperations` signature growth** | Add `IPlayerInventoryStore` + `TimeProvider` to accept/advance for wiring; keep complete unchanged | **adopted** |
|
||||
| **Terminal step auto-complete without explicit advance call** | Wiring calls `TryMarkComplete` when last step objectives satisfied (operations layer idempotent) | **adopted** |
|
||||
| **Multi-objective steps** | Implement generic all-objectives-satisfied check even though prototype steps are single-objective | **adopted** |
|
||||
| **Encounter done before combat quest accept** | Out of scope; manual QA accepts before combat | **deferred** |
|
||||
| **NEO-117 review nits reused ids** | Use `PrototypeE7M1QuestCatalogRules` constants in wiring tests | **adopted** |
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
# NEO-119 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-119 |
|
||||
| **Title** | E7M1-08: GET /game/players/{id}/quest-progress |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-119/e7m1-08-get-gameplayersidquest-progress |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-08** |
|
||||
| **Branch** | `NEO-119-get-quest-progress` |
|
||||
| **Precursor** | [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) — `QuestObjectiveWiring` gather/craft/encounter + `inventory_has_item` (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-108](NEO-108-implementation-plan.md) — registry-backed player GET + position gate + Bruno; [NEO-115](NEO-115-implementation-plan.md) — quest registry ordering |
|
||||
| **Blocks** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — client quest progress HUD (E7M1-11) |
|
||||
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — **`quest_progress_client.gd`** + HUD labels poll this GET after gather/craft/encounter mutations (E7M1-11). Bruno-only verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **GET-side `inventory_has_item` re-eval** | Should GET re-run inventory snapshot wiring before building the response? (NEO-118 deferred “GET polling” to NEO-119.) | **Re-eval on GET** — call wiring inventory refresh + step completion pass so HUD reflects pre-held tokens (e.g. chain terminal **`contract_handoff_token`** from combat intro) without another inventory mutation. | **Adopted** — user confirmed |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / precedent):**
|
||||
|
||||
- **`status`** strings: `not_started` / `active` / `completed` (NEO-116 implicit missing row → `not_started` at HTTP layer).
|
||||
- **`objectiveCounters`**: object map `{ objectiveId: count }` — mirrors store JSONB and current-step-only semantics.
|
||||
- **Player gate:** `IPositionStateStore.TryGetPosition` → **404** for unknown/blank ids (encounter-progress / gig-progression precedent).
|
||||
- **Quest roster:** all catalog quests via **`GetDefinitionsInIdOrder()`**; ordinal `id` order matches NEO-115.
|
||||
- **Manual QA doc:** skip `docs/manual-qa/NEO-119.md` — server-only; Bruno + API integration tests (NEO-108 / NEO-115 precedent). Accept flows in Bruno wait for [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose authoritative per-player quest progress over HTTP so clients and Bruno can read server-owned status, step index, objective counters, and completion timestamp without local objective math.
|
||||
|
||||
**In scope (from Linear + [E7M1-08](E7M1-prototype-backlog.md#e7m1-08--get-gameplayersidquest-progress)):**
|
||||
|
||||
- **`GET /game/players/{id}/quest-progress`** with versioned envelope (`schemaVersion` **1**, **`playerId`**, **`quests`** array).
|
||||
- Per-quest row: **`questId`**, **`status`**, **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`** when **`completed`**.
|
||||
- Projection from **`IQuestDefinitionRegistry`** + **`IPlayerQuestStateStore`** (missing row ⇒ **`not_started`**).
|
||||
- **GET hook:** best-effort **`inventory_has_item`** refresh + step auto-advance/complete before snapshot (kickoff decision).
|
||||
- Known-player gate (position store).
|
||||
- Bruno `bruno/neon-sprawl-server/quest-progress/` — default read + shape assertions.
|
||||
- Integration tests (AAA): unknown player 404; default all `not_started`; accept → partial counter → completed terminal step index.
|
||||
- `server/README.md` route section.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Quest definition catalog GET ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — already landed).
|
||||
- **`POST …/quests/{questId}/accept`** ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) / E7M1-09).
|
||||
- Godot HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)).
|
||||
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)).
|
||||
- Dev fixture reset API for quest rows.
|
||||
- `docs/manual-qa/NEO-119.md` (server-only per kickoff).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] GET returns stable v1 envelope for **`dev-local-1`** player (four quests, `schemaVersion` 1).
|
||||
- [x] Completed quests show **`completed`** with **`currentStepIndex`** at terminal step (0-based; e.g. operator chain index **3**).
|
||||
- [x] Bruno + integration tests pass in CI.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Route:** `GET /game/players/{id}/quest-progress` — `QuestProgressApi.BuildSnapshot`; 404 via `IPositionStateStore`.
|
||||
- **GET hook:** `QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer` — inventory refresh + step completion before snapshot.
|
||||
- **DTOs:** `QuestProgressListResponse` (schema v1); row `status` not_started/active/completed; `objectiveCounters` map; `completedAt` when completed.
|
||||
- **Tests:** seven AAA cases in `QuestProgressApiTests`.
|
||||
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru`.
|
||||
- **Docs:** `server/README.md`; E7.M1 module snapshot + alignment register + backlog updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`GET /game/players/{id}/quest-progress`** — inject **`IPositionStateStore`**, **`IQuestDefinitionRegistry`**, **`IPlayerQuestStateStore`**, **`IPlayerInventoryStore`**, **`IItemDefinitionRegistry`**, **`TimeProvider`**.
|
||||
|
||||
2. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`EncounterProgressApi`**).
|
||||
|
||||
3. **GET-side wiring (kickoff):** Before building JSON, best-effort call a new public helper on **`QuestObjectiveWiring`** (e.g. **`TryRefreshInventoryProgressForPlayer`**) that:
|
||||
- wraps existing **`RefreshInventoryHasItemCountersForPlayer`**
|
||||
- then runs the same step-completion sweep as gather/craft paths (**`TryCompleteAllActiveQuests`** — extract from private to shared internal/public entry)
|
||||
- swallows/logs at debug; never fails the HTTP response
|
||||
|
||||
4. **Status derivation** (per catalog quest id):
|
||||
|
||||
| Store row | **`status`** | **`currentStepIndex`** | **`objectiveCounters`** | **`completedAt`** |
|
||||
|-----------|--------------|------------------------|-------------------------|-------------------|
|
||||
| Missing | `not_started` | `0` | `{}` | omitted |
|
||||
| Active | `active` | row index | row counters map | omitted |
|
||||
| Completed | `completed` | terminal index (unchanged at complete) | last-step counters (may be empty after terminal satisfy) | ISO-8601 from row |
|
||||
|
||||
5. **Ordering:** Build rows from **`questRegistry.GetDefinitionsInIdOrder()`** — ids match **`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`** ordinal order.
|
||||
|
||||
6. **Implementation:** **`QuestProgressApi`** + **`QuestProgressListDtos.cs`** with internal **`BuildSnapshot(playerId, …)`** testable like **`EncounterProgressApi.BuildSnapshot`**. Register **`app.MapQuestProgressApi()`** in **`Program.cs`** after **`MapEncounterProgressApi()`**.
|
||||
|
||||
7. **Bruno:** `bruno/neon-sprawl-server/quest-progress/`
|
||||
- **`Get quest progress default.bru`** — GET **`dev-local-1`** → 200, `schemaVersion` 1, `quests.length === 4`, all `not_started`, empty counters.
|
||||
- Keep existing health smoke request.
|
||||
- Accept / partial-progress Bruno flows deferred until NEO-120 POST lands.
|
||||
|
||||
8. **Docs:** Add **`server/README.md`** GET section (curl example, GET inventory refresh note). Update NEO-118 README deferral line. Module snapshot / alignment register / E7M1 backlog on story land.
|
||||
|
||||
### Expected v1 envelope (prototype default)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"playerId": "dev-local-1",
|
||||
"quests": [
|
||||
{ "questId": "prototype_quest_combat_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} },
|
||||
{ "questId": "prototype_quest_gather_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} },
|
||||
{ "questId": "prototype_quest_operator_chain", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} },
|
||||
{ "questId": "prototype_quest_refine_intro", "status": "not_started", "currentStepIndex": 0, "objectiveCounters": {} }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Wiring flow (GET)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R[GET quest-progress] --> G{Known player?}
|
||||
G -->|no| N404[404]
|
||||
G -->|yes| W[QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer]
|
||||
W --> B[BuildSnapshot registry + store]
|
||||
B --> J[JSON v1 response]
|
||||
```
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | `Map*` extension; player gate; GET inventory refresh hook; `BuildSnapshot`. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestProgressListDtos.cs` | Versioned list response + per-quest row DTOs (`status`, counters map). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestProgressApiTests.cs` | AAA HTTP integration: 404, default not_started, active partial, completed terminal index, GET inventory refresh. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru` | Bruno default-state verification. |
|
||||
| `docs/plans/NEO-119-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveWiring.cs` | Public GET-hook entry (`TryRefreshInventoryProgressForPlayer`) sharing refresh + step-completion sweep. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestProgressApi()` after encounter progress API. |
|
||||
| `server/README.md` | Document GET route, response shape, GET-side `inventory_has_item` refresh; replace E7M1-08 deferral stubs. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | HTTP quest-progress read bullet (on story complete). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-119 HTTP (on story complete). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-119 (on story complete). |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-08 acceptance checkboxes (on story complete). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `QuestProgressApiTests.cs` | **404:** unknown player + whitespace id. **Default:** GET `dev-local-1` → schema v1, four quests in ordinal id order, all `not_started`, `currentStepIndex` 0, empty `objectiveCounters`. **Active partial:** `QuestStateOperations.TryAccept` gather intro + `TryUpdateObjectiveCounter` → GET shows `active`, counter 2/3 on gather objective. **Completed:** accept + satisfy gather intro via operations/wiring helpers → GET shows `completed`, `currentStepIndex` 0, `completedAt` present. **Chain terminal:** operator chain completed → `currentStepIndex` 3. **GET refresh:** active chain on terminal `inventory_has_item` step with token pre-seeded in inventory (no new grant) → GET triggers complete (side-effecting read). |
|
||||
| Existing quest test suite | Regression smoke — no behavior change to wiring stores when GET not called. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Side-effecting GET** | Adopted per kickoff — document in README; mirror deferred NEO-118 “polling hook” intent for client HUD | **adopted** |
|
||||
| **Private `TryCompleteAllActiveQuests`** | Extract shared internal method invoked by GET hook and existing gather/craft/encounter paths | **adopted** |
|
||||
| **Bruno without accept POST** | Default-state Bruno only this story; accept spine Bruno joins NEO-120 | **adopted** |
|
||||
| **Completed row counters** | Project store counters as-is (terminal step may show satisfied counts or empty after advance logic) — assert `status` + `currentStepIndex` + `completedAt` in tests | **adopted** |
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# NEO-120 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-120 |
|
||||
| **Title** | E7M1-09: POST /game/players/{id}/quests/{questId}/accept |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-120/e7m1-09-post-gameplayersidquestsquestidaccept |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-09** |
|
||||
| **Branch** | `NEO-120-quest-accept-post` |
|
||||
| **Precursor** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations.TryAccept` (**landed on `main`**); [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — GET quest-progress (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-117](NEO-117-implementation-plan.md) — operations + reason codes; [NEO-119](NEO-119-implementation-plan.md) — quest progress row JSON; [NEO-55](NEO-55-implementation-plan.md) / `PlayerInventoryApi` — mutation envelope (`applied` + `reasonCode` + nested snapshot, HTTP **200** on deny) |
|
||||
| **Blocks** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — client quest progress + accept HUD (E7M1-11) |
|
||||
| **Client counterpart** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — accept binding + feedback label polls this POST then GET quest-progress. Bruno-only accept verification is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** [E7M1-09](E7M1-prototype-backlog.md#e7m1-09--post-gameplayersidquestsquestidaccept) backlog, Linear AC, landed [NEO-117](NEO-117-implementation-plan.md) operations, and mutation HTTP precedents (`PlayerInventoryApi`, `PlayerCraftApi`, `AbilityCastApi`) settle scope:
|
||||
|
||||
- **Response envelope:** `accepted` + optional `reasonCode` + optional single **`quest`** row (`QuestProgressRowJson` shape from NEO-119) — backlog “updated progress row or deny payload”; inventory-style **200** on structured deny.
|
||||
- **Request body:** optional v1 — omit body, empty body, or `{ "schemaVersion": 1 }` all valid; **400** only when body is present with wrong `schemaVersion`.
|
||||
- **Player gate:** `IPositionStateStore` → **404** for unknown/blank ids (GET quest-progress / inventory precedent) before `TryAccept`.
|
||||
- **Unknown quest:** **200** + `accepted: false` + `reasonCode: unknown_quest` (operations layer; not HTTP 404).
|
||||
- **Deny with snapshot:** `already_active` / `already_completed` include current **`quest`** row from operations snapshot (no mutation on deny).
|
||||
- **Manual QA doc:** skip `docs/manual-qa/NEO-120.md` — server-only; Bruno + API integration tests (NEO-119 precedent).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Expose player-initiated quest accept over HTTP so Godot (and Bruno) can transition a quest from **`not_started` → `active`** with structured deny feedback when prerequisites or state block accept.
|
||||
|
||||
**In scope (from Linear + [E7M1-09](E7M1-prototype-backlog.md#e7m1-09--post-gameplayersidquestsquestidaccept)):**
|
||||
|
||||
- **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi`; optional request body v1; response with **`accepted`**, optional **`reasonCode`**, optional **`quest`** progress row.
|
||||
- Wire **`QuestStateOperations.TryAccept`** (inventory refresh + step completion on accept already in operations).
|
||||
- Shared row projection with GET (`QuestProgressRowJson` from `QuestStepState` / missing row).
|
||||
- Bruno `bruno/neon-sprawl-server/quest-progress/` — happy accept, prerequisite deny, duplicate accept.
|
||||
- Integration tests (AAA): 404 player gate; happy accept; `prerequisite_incomplete`; `already_active`; `unknown_quest`.
|
||||
- `server/README.md` accept route section.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Abandon/reset POST.
|
||||
- Godot HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)).
|
||||
- Telemetry hook comments ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)).
|
||||
- Dev fixture reset API for quest rows.
|
||||
- `docs/manual-qa/NEO-120.md` (server-only per kickoff).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Successful accept transitions row **`not_started` → `active`**.
|
||||
- [x] Deny returns structured **`reasonCode`** without mutation.
|
||||
- [x] Bruno + integration tests pass in CI.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Route:** `POST /game/players/{id}/quests/{questId}/accept` — `QuestAcceptApi.MapResponse`; 404 via `IPositionStateStore`; optional body v1.
|
||||
- **DTOs:** `QuestAcceptRequest`, `QuestAcceptResponse` (`accepted`, `reasonCode`, `quest` row).
|
||||
- **Shared mapper:** `QuestProgressApi.MapQuestProgressRow` overloads for GET + accept POST.
|
||||
- **Tests:** ten AAA cases in `QuestAcceptApiTests` (incl. `already_completed` deny snapshot, empty `{}` body).
|
||||
- **Bruno:** accept gather intro, refine prerequisite deny, duplicate accept.
|
||||
- **Docs:** `server/README.md`; E7.M1 module snapshot + backlog updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Route:** **`POST /game/players/{id}/quests/{questId}/accept`** — inject **`IPositionStateStore`**, **`IQuestDefinitionRegistry`**, **`IPlayerQuestStateStore`**, **`IPlayerInventoryStore`**, **`IItemDefinitionRegistry`**, **`TimeProvider`**.
|
||||
|
||||
2. **Request:** `QuestAcceptRequest` with `schemaVersion` **1**. Handler accepts **`null`**, empty JSON, or `{ "schemaVersion": 1 }`. **400** when body is non-null and `schemaVersion` ≠ 1.
|
||||
|
||||
3. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`QuestProgressApi`** GET).
|
||||
|
||||
4. **Accept:** `QuestStateOperations.TryAccept(trimmedId, questId, …)` — `questId` from route (operations normalizes via registry).
|
||||
|
||||
5. **Response mapping** (`QuestAcceptResponse` v1):
|
||||
|
||||
| Outcome | HTTP | `accepted` | `reasonCode` | `quest` row |
|
||||
|---------|------|------------|--------------|-------------|
|
||||
| Success | 200 | `true` | omitted | active row from snapshot |
|
||||
| Deny (no snapshot) | 200 | `false` | operations code | omitted |
|
||||
| Deny (with snapshot) | 200 | `false` | operations code | row from snapshot (`already_active`, `already_completed`) |
|
||||
|
||||
Map `QuestStepState` → `QuestProgressRowJson` via shared helper extracted from **`QuestProgressApi.MapRow`** (e.g. **`MapQuestProgressRow`**) so GET and POST stay aligned.
|
||||
|
||||
6. **Register:** `app.MapQuestAcceptApi()` in **`Program.cs`** immediately after **`MapQuestProgressApi()`**.
|
||||
|
||||
7. **Bruno** (`bruno/neon-sprawl-server/quest-progress/`):
|
||||
|
||||
- **`Accept gather intro.bru`** — POST `dev-local-1` / `prototype_quest_gather_intro` → 200, `accepted: true`, `quest.status === active`, `currentStepIndex === 0`.
|
||||
- **`Accept refine prerequisite deny.bru`** — POST refine intro without gather complete → 200, `accepted: false`, `reasonCode === prerequisite_incomplete`, no active refine row on subsequent GET.
|
||||
- **`Accept duplicate.bru`** — accept gather twice → second 200, `accepted: false`, `reasonCode === already_active`, `quest.status === active`.
|
||||
|
||||
8. **Docs:** Add **`server/README.md`** POST accept section (curl, response shape, reason codes). Update E7.M1 module snapshot / alignment register / E7M1 backlog on story land.
|
||||
|
||||
### Expected v1 success response (prototype)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"accepted": true,
|
||||
"quest": {
|
||||
"questId": "prototype_quest_gather_intro",
|
||||
"status": "active",
|
||||
"currentStepIndex": 0,
|
||||
"objectiveCounters": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Expected v1 deny response (prerequisite)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"accepted": false,
|
||||
"reasonCode": "prerequisite_incomplete"
|
||||
}
|
||||
```
|
||||
|
||||
### Accept flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R[POST accept] --> B{Body schema ok?}
|
||||
B -->|no| B400[400]
|
||||
B -->|yes| G{Known player?}
|
||||
G -->|no| N404[404]
|
||||
G -->|yes| A[QuestStateOperations.TryAccept]
|
||||
A --> M[Map QuestAcceptResponse]
|
||||
M --> J[JSON 200]
|
||||
```
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptApi.cs` | `Map*` extension; player gate; `TryAccept`; response mapping. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestAcceptDtos.cs` | Optional request + v1 accept response DTOs. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestAcceptApiTests.cs` | AAA HTTP integration: 404, happy accept, prerequisite deny, duplicate accept, unknown quest. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept gather intro.bru` | Bruno happy-path accept. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept refine prerequisite deny.bru` | Bruno prerequisite deny. |
|
||||
| `bruno/neon-sprawl-server/quest-progress/Accept duplicate.bru` | Bruno duplicate accept deny. |
|
||||
| `docs/plans/NEO-120-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestProgressApi.cs` | Extract shared `MapQuestProgressRow` (or equivalent) for GET + accept POST row projection. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `MapQuestAcceptApi()` after `MapQuestProgressApi()`. |
|
||||
| `server/README.md` | Document POST accept route, request/response shape, reason codes; replace E7M1-09 deferral stub. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | HTTP quest accept bullet (on story complete). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — NEO-120 HTTP (on story complete). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — NEO-120 (on story complete). |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-09 acceptance checkboxes (on story complete). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `QuestAcceptApiTests.cs` | **404:** unknown player + whitespace id. **Happy accept:** POST gather intro → `accepted: true`, `quest.status` active, step 0, empty counters; GET confirms active. **Prerequisite deny:** POST refine intro without gather complete → `prerequisite_incomplete`, GET still `not_started` for refine. **Duplicate accept:** second POST → `already_active` with `quest` snapshot. **Completed deny:** accept + complete gather intro → re-accept → `already_completed` with `quest.status` completed + `completedAt`. **Unknown quest:** bogus quest id → `unknown_quest`. **Optional body:** POST with no body or `{}` succeeds for happy accept. |
|
||||
| Existing quest test suite | Regression smoke — operations + GET unchanged when accept not called. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Response field `accepted` vs `applied`** | Use **`accepted`** — matches `AbilityCastResponse` / perk `selected` mutation naming; backlog does not mandate `applied` | **adopted** |
|
||||
| **Shared row mapper location** | Extract from `QuestProgressApi` to internal static helper in same file or small shared type — avoid duplicating status/counter mapping | **adopted** |
|
||||
| **Accept may auto-complete first step** | Operations already run inventory wiring + step completion on accept (NEO-118); tests assert post-accept row as returned, not forced step 0 if wiring advances | **adopted** |
|
||||
| **Bruno collection ordering** | Add accept `.bru` files beside existing quest-progress folder; no new collection root | **adopted** |
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# NEO-121 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-121 |
|
||||
| **Title** | E7M1-10: Quest telemetry hook sites (comment-only) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-121/e7m1-10-quest-telemetry-hook-sites-comment-only |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-10** |
|
||||
| **Branch** | `NEO-121-quest-telemetry-hooks` |
|
||||
| **Precursor** | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) — `QuestStateOperations` (**landed on `main`**); [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) — `QuestObjectiveWiring` (**landed on `main`**); [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) / [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — quest progress GET + accept POST (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-64](NEO-64-implementation-plan.md) / [NEO-56](NEO-56-implementation-plan.md) / [NEO-49](NEO-49-implementation-plan.md) — comment-only engine anchors + `TODO(E9.M1)`; engine-only (no duplicate API-layer hooks) |
|
||||
| **Client counterpart** | none (infrastructure-only per [E7M1-10](E7M1-prototype-backlog.md#e7m1-10--quest-telemetry-hook-sites-comment-only)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** [E7M1-10](E7M1-prototype-backlog.md#e7m1-10--quest-telemetry-hook-sites-comment-only) backlog, Linear AC, landed quest operations/wiring (NEO-117/118), and comment-only telemetry precedents (NEO-49/56/64/71) settle scope:
|
||||
|
||||
- **Runtime behavior:** comments-only + `TODO(E9.M1)` — no production ingest, no `ILogger` (NEO-49/56 precedent).
|
||||
- **Hook anchor:** **`QuestStateOperations`** only for **`quest_start`**, **`quest_step_complete`**, **`quest_complete`** — HTTP accept POST and objective wiring delegate to the engine; no duplicate API-layer or wiring-layer hooks (NEO-56 engine-only precedent). **`QuestObjectiveWiring`** triggers step advance/complete by calling **`QuestStateOperations`**; hooks live at mutation commit sites.
|
||||
- **`quest_start` timing:** after successful **`TryActivate`** in **`TryAccept`** (first activation only; deny paths excluded).
|
||||
- **`quest_step_complete` timing:** after successful **`TryAdvanceStep`** store commit (covers wiring-driven multi-step chains — one emit per committed advance).
|
||||
- **`quest_complete` timing:** after first-time **`TryMarkComplete`** store commit; **not** on idempotent replay when row is already **`completed`** (NEO-117 idempotency + NEO-49 “idempotent reevaluation produces none” precedent).
|
||||
- **Optional deny hook:** include **`quest_accept_denied`** comment in **`TryAccept`** **`Deny`** returns with stable **`reasonCode`** (NEO-56 **`inventory_transfer_denied`** pattern; backlog lists as optional — adopt for accept funnel parity).
|
||||
- **Manual QA doc:** **`docs/manual-qa/NEO-121.md`** — NEO-64/49 comment-only verification pattern (no behavior/API change).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Document and place **comment-only hook sites** on the server-authoritative quest state path for future E9.M1 catalog events **`quest_start`**, **`quest_step_complete`**, and **`quest_complete`**. Align with Epic 7 Slice 1 telemetry vocabulary ([E7_M1 module](../decomposition/modules/E7_M1_QuestStateMachine.md#risks-and-telemetry)).
|
||||
|
||||
**In scope (from Linear + [E7M1-10](E7M1-prototype-backlog.md#e7m1-10--quest-telemetry-hook-sites-comment-only)):**
|
||||
|
||||
- Hook sites in **`QuestStateOperations`** at accept, step advance, and complete commit paths.
|
||||
- Reserved deny hook **`quest_accept_denied`** (comment-only) on **`TryAccept`** deny paths.
|
||||
- **`server/README.md`** quest telemetry subsection.
|
||||
- **`E7_M1_QuestStateMachine.md`** implementation snapshot note.
|
||||
- **`docs/manual-qa/NEO-121.md`** — verify comments + no behavior change.
|
||||
- E7M1 backlog + alignment register updates when implementation completes.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- E9.M1 ingest pipeline; runtime telemetry emit.
|
||||
- `ILogger` / metrics / dev-only log lines.
|
||||
- Duplicate hook comments in **`QuestAcceptApi`**, **`QuestProgressApi`**, or **`QuestObjectiveWiring`** (engine-only precedent).
|
||||
- Client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Hook sites exist at accept, step complete, quest complete paths.
|
||||
- [x] Epic 7 Slice 1 telemetry vocabulary documented in `server/README.md`.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Engine anchor:** **`QuestStateOperations`** — four comment-only hook sites with **`TODO(E9.M1)`** (no runtime emit).
|
||||
- **`quest_start`:** after successful **`TryActivate`** in **`TryAccept`**.
|
||||
- **`quest_step_complete`:** after successful **`TryAdvanceStep`** store commit.
|
||||
- **`quest_complete`:** after first-time **`TryMarkComplete`** commit (not idempotent replay).
|
||||
- **`quest_accept_denied`:** **`DenyAccept(playerId, questId, …)`** on all **`TryAccept`** deny paths; optional accept context on **`DenyFromProgressSnapshot`** for activate-failure rereads.
|
||||
- **Docs:** `server/README.md` quest telemetry subsection; E7.M1 module + alignment register + E7M1 backlog updated.
|
||||
- **Manual QA:** `docs/manual-qa/NEO-121.md`.
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Authoritative surface:** **`QuestStateOperations`** — all quest lifecycle mutations funnel here. **`QuestObjectiveWiring`** calls **`TryAdvanceStep`** / **`TryMarkComplete`**; **`QuestAcceptApi`** calls **`TryAccept`**.
|
||||
|
||||
2. **Hook site A — `quest_start`:** In **`TryAccept`**, immediately after **`progressStore.TryActivate`** returns **`true`** (before objective wiring refresh):
|
||||
- Comment block names future E9.M1 event **`quest_start`**.
|
||||
- **`TODO(E9.M1): catalog emit`** (NEO-49 style).
|
||||
- Planned payload fields: `playerId`, `questId`, `currentStepIndex` (0), optional `schemaVersion`.
|
||||
|
||||
3. **Hook site B — `quest_step_complete`:** In **`TryAdvanceStep`**, immediately after **`progressStore.TryAdvanceStep`** returns **`true`**:
|
||||
- Comment block names future E9.M1 event **`quest_step_complete`**.
|
||||
- **`TODO(E9.M1): catalog emit`** — emit once per committed step advance (chain quests may emit multiple in one wiring pass).
|
||||
- Planned payload fields: `playerId`, `questId`, `completedStepIndex` (prior index), `newStepIndex`, optional step id from catalog at E9.M1 wiring time.
|
||||
|
||||
4. **Hook site C — `quest_complete`:** In **`TryMarkComplete`**, immediately after **`progressStore.TryMarkComplete`** returns **`true`** on the first-time completion path (after the early idempotent **`completed`** return):
|
||||
- Comment block names future E9.M1 event **`quest_complete`**.
|
||||
- **`TODO(E9.M1): catalog emit`** — not on idempotent replay.
|
||||
- Planned payload fields: `playerId`, `questId`, `completedAt`, optional total step count.
|
||||
|
||||
5. **Hook site D (optional) — `quest_accept_denied`:** On **`TryAccept`** deny returns (central **`Deny`** helper and/or each deny branch before return):
|
||||
- Comment block names future E9.M1 event **`quest_accept_denied`**.
|
||||
- **`TODO(E9.M1): catalog emit`** with stable **`reasonCode`** (`unknown_quest`, `prerequisite_incomplete`, `already_active`, `already_completed`, etc.).
|
||||
- Planned payload fields: `playerId`, `questId`, `reasonCode`.
|
||||
|
||||
6. **Cross-references (minimal):**
|
||||
- Update **`QuestStateOperations`** class summary → NEO-121 telemetry hook anchor.
|
||||
- **`server/README.md`:** new **Quest telemetry hooks (NEO-121)** subsection after quest accept POST (NEO-120).
|
||||
- **`E7_M1_QuestStateMachine.md`:** NEO-121 landed line under Risks and telemetry.
|
||||
- **`documentation_and_implementation_alignment.md`** + **`module_dependency_register.md`:** E7.M1 row — E7M1-10 when complete.
|
||||
- **`E7M1-prototype-backlog.md`:** E7M1-10 checkboxes + landed note.
|
||||
|
||||
7. **Accept + instant wiring:** **`TryAccept`** may trigger **`quest_start`**, then wiring may emit **`quest_step_complete`** / **`quest_complete`** in the same call stack — order is preserved by code flow (activate → wiring → advance/complete).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/manual-qa/NEO-121.md` | Manual QA: verify comment blocks + no behavior/API change. |
|
||||
| `docs/plans/NEO-121-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs` | **NEO-121:** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites at mutation/deny commit paths. |
|
||||
| `server/README.md` | Document quest telemetry hook placement and Epic 7 Slice 1 vocabulary (NEO-121). |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | Implementation snapshot: NEO-121 telemetry hooks landed. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row: NEO-121 hook sites. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 note — E7M1-10 complete when story lands. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-10 checkboxes + landed note. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| *(none added or changed)* | Comments-only change; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing **`QuestStateOperationsTests`**, **`QuestObjectiveWiringTests`**, quest HTTP integration tests unchanged). Manual verification: **`docs/manual-qa/NEO-121.md`**. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Duplicate emits on accept + wiring same tick** | Expected — **`quest_start`** then zero or more **`quest_step_complete`** / **`quest_complete`**; E9.M1 can correlate by session/time or sample funnels. | **adopted** |
|
||||
| **`quest_accept_denied` scope** | Include on all **`TryAccept`** deny paths with **`reasonCode`** (NEO-56 deny pattern). | **adopted** |
|
||||
| **Module Ready vs In Progress** | Leave E7.M1 **In Progress** until client capstones NEO-122/123 land; only note E7M1-10 complete in register. | **adopted** |
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
# NEO-122 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-122 |
|
||||
| **Title** | E7M1-11: Client quest progress + accept HUD (Godot) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-122/e7m1-11-client-quest-progress-accept-hud-godot |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-11** |
|
||||
| **Branch** | `NEO-122-client-quest-progress-accept-hud` |
|
||||
| **Server deps** | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) — `GET /game/players/{id}/quest-progress` (**landed on `main`**); [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — `POST …/quests/{questId}/accept` (**landed on `main`**); [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) — `GET /game/world/quest-definitions` (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-110](NEO-110-implementation-plan.md) — thin HTTP client, `main.gd` HUD render, event-driven refresh; [NEO-74](NEO-74-implementation-plan.md) — world-definitions client + POST mutation client; [NEO-72](NEO-72-implementation-plan.md) — display-name cache for HUD lines |
|
||||
| **Blocks** | [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) — playable onboarding quest chain capstone (Godot manual QA) |
|
||||
| **Server counterpart** | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) + [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) — authoritative GET/POST; Bruno is **not** prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Quest definitions client** | Include optional `quest_definitions_client.gd`? | **Include** — NEO-115 GET landed; `recipe_definitions_client.gd` precedent; readable four-quest HUD. | **Adopted** — include |
|
||||
| **Accept key bindings** | **Q** / **Shift+Q** semantics? | **Q** = explicit `prototype_quest_gather_intro`; **Shift+Q** = first catalog-order `not_started` quest with all prerequisites `completed` in current snapshot. | **Adopted** |
|
||||
| **HUD quest list scope** | How many quests on **`QuestProgressLabel`**? | **All four** catalog quests, compact status per row (matches GET roster; supports chain visibility for NEO-123). | **Adopted** |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / precedent):**
|
||||
|
||||
- **Refresh strategy:** boot hydrate + refresh after gather (interaction success → inventory finalize path), craft success, cast with **`targetDefeated`**, and successful accept POST — mirrors NEO-110 encounter + NEO-73 skill refresh; no periodic poll.
|
||||
- **Error surfacing:** sync/accept failures paint visible HUD copy (`error — {reason}`) matching interaction/gather feedback pattern.
|
||||
- **Accept POST body:** optional v1 `{ "schemaVersion": 1 }` (NEO-120); structured deny is HTTP **200** with **`accepted: false`**.
|
||||
- **HUD placement:** **`UICanvas/HudRoot`** after **`EncounterCompleteLabel`**, before **`NpcStateLabel`** — quest spans gather/craft/combat loop (NEO-110 combat-area precedent extended).
|
||||
- **Server changes:** none — client-only story.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Godot shows **server-owned** quest progress and accept feedback using **`GET …/quest-progress`** and **`POST …/quests/{questId}/accept`** — player-visible status after accept and after gather/craft/encounter objective events without Bruno.
|
||||
|
||||
**In scope (from Linear + [E7M1-11](E7M1-prototype-backlog.md#e7m1-11--client-quest-progress-accept-hud-godot)):**
|
||||
|
||||
- **`quest_progress_client.gd`:** GET snapshot parse v1; **`quest_row(quest_id, snapshot)`** helper; POST accept with optional v1 body; signals **`quest_progress_received`** / **`quest_sync_failed`** / **`quest_accept_result_received`** / **`quest_accept_failed`**; **`request_sync_from_server()`**, **`request_accept(quest_id)`**; **`push_warning`** on failures.
|
||||
- **`quest_definitions_client.gd`:** GET **`/game/world/quest-definitions`**; cache ordered quest rows; **`display_name_for(quest_id)`**; signals **`definitions_ready`** / **`definitions_sync_failed`**.
|
||||
- **`QuestProgressLabel`** — all four quests, compact status/step/counter summary.
|
||||
- **`QuestAcceptFeedbackLabel`** — accept success/deny/failure copy.
|
||||
- **`main.gd`:** boot hydrate (defs + progress); event-driven GET refresh; **Q** / **Shift+Q** accept bindings in **`_unhandled_key_input`**; eligible-quest picker for Shift+Q (catalog order + prerequisite check against snapshot).
|
||||
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
|
||||
- **`docs/manual-qa/NEO-122.md`** — Godot steps (server + client; no Bruno).
|
||||
- **`client/README.md`** quest HUD subsection.
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Quest journal art, map pins, NPC dialog; final quest picker UI.
|
||||
- Abandon/reset POST.
|
||||
- Server route/DTO changes.
|
||||
- Full four-quest capstone script (**[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)**).
|
||||
- Periodic quest poll timer.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Player sees quest status change after accept without Bruno.
|
||||
- [x] Objective progress updates after gather/craft/encounter in Godot session.
|
||||
- [x] Failed GET/accept surfaces visible HUD error (match interaction client pattern).
|
||||
- [x] `docs/manual-qa/NEO-122.md` Godot steps exercisable (human QA passed 2026-06-07).
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **`quest_progress_client.gd`:** GET parse v1 + POST accept; separate sync/accept HTTP; **`quest_row`**; signals.
|
||||
- **`quest_definitions_client.gd`:** GET world quest-definitions; **`display_name_for`** + **`quests_snapshot()`**.
|
||||
- **`main.gd`:** boot hydrate; Q / Shift+Q accept; refresh after gather/craft/**`targetDefeated`**/**encounter `completed`**/accept; HUD render.
|
||||
- **Scene:** **`QuestProgressClient`**, **`QuestDefinitionsClient`**, **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`** after encounter labels.
|
||||
- **Tests:** `client/test/quest_progress_client_test.gd` (12 cases across both suites), `client/test/quest_definitions_client_test.gd`.
|
||||
- **Docs:** `client/README.md` quest HUD section; `docs/manual-qa/NEO-122.md`.
|
||||
|
||||
### NEO-122 follow-up (economy HUD layout — manual QA step 7)
|
||||
|
||||
During NEO-122 QA, **`refine_scrap_standard`** was unreachable when the economy block sat below the viewport inside the tall **`HudRoot`** stack. Follow-up commits (post–first review) refactored prototype HUD layout **without changing quest contracts**:
|
||||
|
||||
- **`HudRootScroll`** — **`ScrollContainer`** wrapping debug/combat/quest labels; height clamped above the economy panel.
|
||||
- **`EconomyHudSection`** — reparented to **`UICanvas`**; pinned bottom-left via **`prototype_economy_hud_section.gd`** **`_apply_viewport_layout`** (explicit **`position`/`size`**, opaque panel fill, **`z_index`**).
|
||||
- **`BodyScroll/Body`** — single economy scroll; child order **Recipes → Inventory → Skills → Gig XP** so craft rows appear first.
|
||||
- **`craft_recipe_panel.gd`** — flattened to **`VBoxContainer`** + **`RecipeRows`** (no nested scroll); populate resets economy body scroll to recipes via parent **`scroll_body_to_recipes()`**.
|
||||
- **Tests:** `client/test/craft_recipe_panel_test.gd`; viewport layout tests in **`prototype_economy_hud_section_test.gd`**.
|
||||
- **Regression:** spot-check [NEO-74](../manual-qa/NEO-74.md), [NEO-75](../manual-qa/NEO-75.md), [NEO-110](../manual-qa/NEO-110.md) after merge (collapsed economy toggle, feedback labels in **`HudRootScroll`**, encounter labels scroll when stack is tall).
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Server contract (landed — NEO-119 / NEO-120 / NEO-115)
|
||||
|
||||
- **`GET /game/players/{id}/quest-progress`** → **`schemaVersion` 1**, **`playerId`**, **`quests[]`** with **`questId`**, **`status`** (`not_started` | `active` | `completed`), **`currentStepIndex`**, **`objectiveCounters`**, optional **`completedAt`**.
|
||||
- **`POST /game/players/{id}/quests/{questId}/accept`** → **`schemaVersion` 1**, **`accepted`**, optional **`reasonCode`**, optional **`quest`** row (same shape as GET entry). HTTP **200** on structured deny.
|
||||
- **`GET /game/world/quest-definitions`** → **`schemaVersion` 1**, **`quests[]`** with **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, nested **`steps`**.
|
||||
|
||||
### Client constants (`main.gd`)
|
||||
|
||||
```gdscript
|
||||
const PROTOTYPE_QUEST_GATHER_ID := "prototype_quest_gather_intro"
|
||||
```
|
||||
|
||||
### 1. `quest_progress_client.gd`
|
||||
|
||||
- Mirror **`encounter_progress_client.gd`** for GET: injectable HTTP, **`_sync_busy`** guard, 404 handling.
|
||||
- Add POST accept on same node with separate **`_accept_busy`** guard (craft-client precedent — do not block GET refresh during accept round-trip).
|
||||
- **`parse_quest_progress_json(text)`** static: require **`schemaVersion` 1**, **`quests`** array, non-empty **`playerId`**.
|
||||
- **`parse_quest_accept_json(text)`** static: require **`schemaVersion` 1**, **`accepted`** bool present.
|
||||
- **`quest_row(quest_id, snapshot)`** → row dict or `{}`.
|
||||
- **`request_accept(quest_id)`** → POST with `{ "schemaVersion": 1 }`; returns **`true`** when queued.
|
||||
|
||||
### 2. `quest_definitions_client.gd`
|
||||
|
||||
- Mirror **`recipe_definitions_client.gd`**: GET **`/game/world/quest-definitions`**, cache **`quests`** array in catalog order.
|
||||
- **`display_name_for(quest_id)`** — fallback raw id.
|
||||
- **`quests_snapshot()`** → cached array copy for eligible-quest iteration in **`main.gd`**.
|
||||
|
||||
### 3. Boot + refresh wiring (`main.gd`)
|
||||
|
||||
- **`_setup_quest_progress_sync()`** from **`_ready()`** after authority HTTP config.
|
||||
- Nodes in **`main.tscn`**: **`QuestProgressClient`**, **`QuestDefinitionsClient`**; **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`** in **`HudRoot`** after **`EncounterCompleteLabel`**.
|
||||
- Boot: **`request_sync_from_server()`** on both clients (parallel with encounter/gig boot).
|
||||
- **`_request_quest_progress_refresh()`** helper; call from:
|
||||
- **`_on_interaction_result_for_gather`** when interaction allowed (alongside skill progression refresh).
|
||||
- **`_on_craft_result_received`** when **`success`**.
|
||||
- **`_on_cast_result_received`** when **`targetDefeated`** (alongside encounter/gig refresh).
|
||||
- **`_on_encounter_progress_received`** when prototype pocket row **`state == "completed"`** (alongside inventory refresh — belt-and-suspenders when defeat-triggered quest GET was skipped or raced).
|
||||
- **`_on_quest_accept_result_received`** after accept (always re-sync full snapshot even when response includes **`quest`** row).
|
||||
- Re-render quest labels when **`definitions_ready`** if progress snapshot already loaded (NEO-110 item-defs re-paint precedent).
|
||||
|
||||
### 4. Accept bindings (`main.gd`)
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| **Q** | **`request_accept(PROTOTYPE_QUEST_GATHER_ID)`** |
|
||||
| **Shift+Q** | First quest in **`quest_definitions_client.quests_snapshot()`** catalog order where progress row **`status == "not_started"`** and every **`prerequisiteQuestIds`** entry has **`status == "completed"`** in **`_last_quest_progress_snapshot`**; if none eligible, paint feedback **`Quest accept: no eligible quest`**. |
|
||||
|
||||
Debounce: skip if **`quest_progress_client`** accept busy (same as craft busy guard).
|
||||
|
||||
### 5. HUD rendering
|
||||
|
||||
**`QuestProgressLabel`** (header **`Quests:`**):
|
||||
|
||||
| State | Copy (per quest, catalog order) |
|
||||
|-------|----------------------------------|
|
||||
| sync error | **`Quests: error — {reason}`** |
|
||||
| loading | **`Quests: Loading…`** |
|
||||
| **`not_started`** | **`{displayName}: not started`** |
|
||||
| **`active`** | **`{displayName}: active step {index+1} {counter summary}`** — e.g. objective counter values joined |
|
||||
| **`completed`** | **`{displayName}: completed`** (+ optional **`completedAt`** suffix) |
|
||||
|
||||
**`QuestAcceptFeedbackLabel`** (header **`Quest accept:`**):
|
||||
|
||||
| Outcome | Copy |
|
||||
|---------|------|
|
||||
| success | **`Quest accept: {displayName} accepted`** |
|
||||
| deny | **`Quest accept: denied — {reasonCode}`** |
|
||||
| HTTP/transport fail | **`Quest accept: failed — {reason}`** |
|
||||
| idle | **`Quest accept: — (Q gather / Shift+Q next)`** |
|
||||
|
||||
### 6. Scene
|
||||
|
||||
- **`main.tscn`:** add client nodes under root; add two labels in **`HudRoot`** VBox **after** **`EncounterCompleteLabel`**, **before** **`NpcStateLabel`**.
|
||||
|
||||
### 7. Tests (GdUnit, AAA)
|
||||
|
||||
- Progress client: parse active/completed rows; **`quest_row`** lookup; GET 404 → **`quest_sync_failed`**; accept happy **`accepted: true`**; accept deny **`accepted: false`** + **`reasonCode`**.
|
||||
- Definitions client: parse four-quest JSON; **`display_name_for`** returns **`displayName`**.
|
||||
|
||||
### 8. Docs on land
|
||||
|
||||
- **`docs/manual-qa/NEO-122.md`**: fresh server; Godot **F5**; **Q** accept gather intro → HUD shows active; gather/craft/defeat updates counters; **Shift+Q** accept refine after gather complete; deny feedback visible.
|
||||
- Update [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md) E7M1-11 checkboxes, [E7_M1](../decomposition/modules/E7_M1_QuestStateMachine.md) client slice note, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-122-implementation-plan.md` | This plan. |
|
||||
| `client/scripts/quest_progress_client.gd` | GET quest-progress + POST accept; parse v1; **`quest_row`**; signals. |
|
||||
| `client/scripts/quest_progress_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/scripts/quest_definitions_client.gd` | GET world quest-definitions; display-name cache. |
|
||||
| `client/scripts/quest_definitions_client.gd.uid` | Godot uid companion (tracked). |
|
||||
| `client/test/quest_progress_client_test.gd` | GdUnit: GET parse, accept happy/deny, 404 failure. |
|
||||
| `client/test/quest_progress_client_test.gd.uid` | GdUnit uid companion. |
|
||||
| `client/test/quest_definitions_client_test.gd` | GdUnit: parse defs JSON; **`display_name_for`**. |
|
||||
| `client/test/quest_definitions_client_test.gd.uid` | GdUnit uid companion. |
|
||||
| `docs/manual-qa/NEO-122.md` | Godot manual QA (accept + progress refresh; no Bruno). |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/scenes/main.tscn` | Add **`QuestProgressClient`**, **`QuestDefinitionsClient`**, **`QuestProgressLabel`**, **`QuestAcceptFeedbackLabel`**. |
|
||||
| `client/scripts/main.gd` | Boot/refresh wiring, Q/Shift+Q accept, HUD render, eligible-quest helper. |
|
||||
| `client/README.md` | Quest progress + accept HUD subsection: server deps NEO-119/120/115, keys, refresh triggers. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| `client/test/quest_progress_client_test.gd` | Mock GET 200: **`not_started`** / **`active`** with **`objectiveCounters`** / **`completed`** rows; **`quest_row`**; GET 404 → **`quest_sync_failed`**; POST accept **`accepted: true`** and **`accepted: false`** + **`reasonCode`**. AAA layout (`# Arrange` / `# Act` / `# Assert`). |
|
||||
| `client/test/quest_definitions_client_test.gd` | Parse v1 defs JSON (four quests); **`display_name_for`** returns catalog **`displayName`**; unknown id fallback. AAA layout. |
|
||||
| `client/test/craft_recipe_panel_test.gd` | Recipe row populate; economy body scroll reset on repopulate; **`craft_requested`** signal. AAA layout. |
|
||||
| `client/test/prototype_economy_hud_section_test.gd` | Economy collapse toggle; viewport pin + **`HudRootScroll`** clamp (NEO-122 follow-up). AAA layout. |
|
||||
|
||||
No new **C#** tests (client-only; server covered by **`QuestProgressApiTests`** / **`QuestAcceptApiTests`**). No Bruno changes.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Item | Agent recommendation | Status |
|
||||
|------|----------------------|--------|
|
||||
| **Concurrent GET + accept** | Separate **`_sync_busy`** / **`_accept_busy`** guards; overlapping GET drops (encounter precedent); accept not blocked by progress poll. | **adopted** |
|
||||
| **Quest defs arrive after progress snapshot** | Re-render **`QuestProgressLabel`** on **`definitions_ready`** when snapshot non-empty (NEO-110 item-defs re-paint). | **adopted** |
|
||||
| **Client-side eligible-quest guess vs server deny** | Shift+Q uses snapshot prerequisite check for UX only; server **`TryAccept`** remains authoritative on deny. | **adopted** |
|
||||
| **NEO-123 capstone overlap** | NEO-122 proves HUD + accept + single-objective refresh; NEO-123 is full four-quest chain manual QA — keep scopes separate. | **deferred** |
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
# NEO-123 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-123 |
|
||||
| **Title** | E7M1-12: Playable onboarding quest chain capstone (Godot) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-123/e7m1-12-playable-onboarding-quest-chain-capstone-godot |
|
||||
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-12** |
|
||||
| **Branch** | `NEO-123-playable-onboarding-quest-chain-capstone` |
|
||||
| **Client deps** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) quest progress + accept HUD (**Done**); [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) encounter clear capstone; [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) economy loop; [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) encounter HUD |
|
||||
| **Server deps** | [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)–[NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) quest spine (**landed on `main`**) |
|
||||
| **Pattern** | Capstone integration — [NEO-111](NEO-111-implementation-plan.md) docs + manual QA primary; minimal gap-fill only if capstone QA fails on `main` |
|
||||
| **Blocked by** | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) (**Done**) |
|
||||
| **Server counterpart** | NEO-112–NEO-121 server spine; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; fix integration gaps only if capstone QA fails on `main` — [NEO-111](NEO-111-implementation-plan.md) precedent. | **Adopted** — docs-primary |
|
||||
| **Capstone intro accept order** | Scripted order for three onboarding intros? | **Shift+Q catalog order:** **Q** gather → **Shift+Q** combat → **Shift+Q** refine → **Shift+Q** operator chain — `prototype_quest_combat_intro` sorts before `prototype_quest_refine_intro` in defs order; matches `quest_hud_controller.gd` `_find_first_eligible_quest_id()`. | **Adopted** — Shift+Q catalog order |
|
||||
| **Idempotency verification** | How to prove no duplicate quest completion on replay? | **Godot stop + F5 restart** (server still running) + retry **Q** / **Shift+Q** on completed quests; HUD statuses unchanged — mirrors [NEO-111](../manual-qa/NEO-111.md). | **Adopted** — Godot restart + duplicate accepts |
|
||||
| **NEO-122 relationship** | Supersede component QA? | **NEO-123 supersedes as Slice 1 capstone**; keep [NEO-122](../manual-qa/NEO-122.md) as component-level regression (NEO-111 / NEO-110 pattern). | **Adopted** (default; no kickoff question) |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / precedent):**
|
||||
|
||||
- **Session baseline:** fresh **server restart** before Godot **F5** — resets in-memory quest progress, inventory, encounter state, and resource-node depletion; zero Bruno/curl in main checklist.
|
||||
- **Economy HUD:** toggle **on** for craft/inventory verification (NEO-75 / NEO-122 precedent).
|
||||
- **Server changes:** none unless capstone QA exposes an authoritative bug (unlikely — covered by server quest tests).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Prove Epic 7 Slice 1 acceptance **in Godot**: complete all four prototype quests (three onboarding intros + one four-step operator chain) without Bruno; satisfies vision **quest loop** gate and [epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#epic-7-definition-of-done).
|
||||
|
||||
**In scope (from Linear + [E7M1-12](E7M1-prototype-backlog.md#e7m1-12--playable-onboarding-quest-chain-capstone-godot)):**
|
||||
|
||||
- **`docs/manual-qa/NEO-123.md`**: numbered **single-session** capstone — fresh server restart; accept intros in **Shift+Q catalog order**; perform gather/craft/combat actions; verify **`prototype_quest_operator_chain`** completes when **`contract_handoff_token`** held; **idempotency** via Godot restart + duplicate accept keys.
|
||||
- **`client/README.md`**: **End-to-end onboarding quest loop** section — integration flow table; cross-links NEO-112–NEO-122 and economy/combat capstones.
|
||||
- **Module alignment** (on story completion): update [E7_M1](../decomposition/modules/E7_M1_QuestStateMachine.md) status, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M1 row, [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M1 note, and [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md) E7M1-12 checkboxes; mark Epic 7 Slice 1 client capstone complete.
|
||||
- **Integration fixes only if capstone QA fails** on current `main` wiring (HUD refresh gap, eligible-accept edge case, chain step stall, idempotency breach, etc.).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- E7.M2 reward bundles; faction reputation; quest VFX art.
|
||||
- Bruno-only verification as prototype-complete proof.
|
||||
- New server HTTP routes or Godot HUD features beyond NEO-122.
|
||||
- Eligible-quest picker redesign (catalog order is the documented contract for Slice 1).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Human completes **`docs/manual-qa/NEO-123.md`** with server + client.
|
||||
- [x] Epic 7 Slice 1 AC: 3 onboarding + 1 chain complete without duplicate completion on replay (idempotency steps pass).
|
||||
- [x] Re-read [epic_07 Definition of Done](../decomposition/epics/epic_07_quest_faction.md#epic-7-definition-of-done) for prototype minimums.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
**Manual QA:** [`docs/manual-qa/NEO-123.md`](../manual-qa/NEO-123.md) — passed (four-quest Godot capstone, Shift+Q catalog accept order, idempotency steps).
|
||||
|
||||
- **`docs/manual-qa/NEO-123.md`** — single-session four-quest capstone with Shift+Q catalog accept order, material math, idempotency steps.
|
||||
- **`client/README.md`** — **End-to-end onboarding quest loop (NEO-123)** section with flow table and cross-links.
|
||||
- **`docs/manual-qa/NEO-122.md`** — catalog-order note + capstone cross-link; expected HUD table aligned with Shift+Q picker.
|
||||
- **`docs/plans/E7M1-prototype-backlog.md`**, **`E7_M1_QuestStateMachine.md`**, **`documentation_and_implementation_alignment.md`**, **`module_dependency_register.md`** — E7M1-12 landed; E7.M1 **Ready**; Epic 7 Slice 1 client capstone complete.
|
||||
- **No integration code changes** — docs-primary scope; server quest spine verified by existing quest API / wiring tests.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Capstone manual QA script (`docs/manual-qa/NEO-123.md`)
|
||||
|
||||
**Preconditions:** Stop any running server; start fresh (`cd server/NeonSprawl.Server && dotnet run`) so in-memory **`dev-local-1`** quest progress, inventory, encounter progress, NPC HP, and resource-node depletion reset. Godot **F5** with **no** prior curl/Bruno seeding. **`Economy HUD`** toggle **on**.
|
||||
|
||||
**Quest catalog (frozen — `content/quests/prototype_quests.json`):**
|
||||
|
||||
| Quest id | Display name | Prerequisites | Steps |
|
||||
|----------|--------------|---------------|-------|
|
||||
| `prototype_quest_gather_intro` | Intro: Salvage Run | none | gather **3** `scrap_metal_bulk` |
|
||||
| `prototype_quest_combat_intro` | Intro: Clear the Pocket | none | `encounter_complete` **`prototype_combat_pocket`** |
|
||||
| `prototype_quest_refine_intro` | Intro: Refine Stock | gather intro | craft **`refine_scrap_standard`** ×1 |
|
||||
| `prototype_quest_operator_chain` | Operator Chain | all three intros | gather ×5 → refine ×1 → **`make_field_stim_mk0`** ×1 → **`inventory_has_item`** **`contract_handoff_token`** ×1 |
|
||||
|
||||
**Accept key contract (NEO-122 / `quest_hud_controller.gd`):**
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| **Q** | Accept **`prototype_quest_gather_intro`** |
|
||||
| **Shift+Q** | First catalog-order **`not_started`** quest with all **`prerequisiteQuestIds`** **`completed`** |
|
||||
|
||||
**HTTP defs catalog order (ordinal `id`):** combat → gather → operator chain → refine. After gather intro completes, **Shift+Q** accepts **combat** (not refine). After combat completes, **Shift+Q** accepts **refine**. After all three intros complete, **Shift+Q** accepts **operator chain**.
|
||||
|
||||
**Expected quest HUD progression (`QuestProgressLabel`):**
|
||||
|
||||
| Phase | Action | Expected HUD lines (high level) |
|
||||
|-------|--------|--------------------------------|
|
||||
| Boot | Godot **F5** | All four quests **`not started`** |
|
||||
| Gather intro | **Q** → gather **3** scrap (**R** at nodes) | Salvage Run **`active`** → counter → **`completed`** |
|
||||
| Combat intro | **Shift+Q** → defeat ×3 NPCs (**Tab** + **1**) | Clear the Pocket **`active`** → **`completed`**; encounter **`completed (3/3)`**; loot includes **`contract_handoff_token`** |
|
||||
| Refine intro | **Shift+Q** → craft **`refine_scrap_standard`** | Refine Stock **`active`** → **`completed`** |
|
||||
| Operator chain | **Shift+Q** → chain steps | Operator Chain **`active step 1…4`** → **`completed`** when token held |
|
||||
| Idempotency | Godot restart + **Q** / **Shift+Q** | All four remain **`completed`**; accept deny / no eligible quest |
|
||||
|
||||
**Material / action notes for script author:**
|
||||
|
||||
- Combat loot grants **`scrap_metal_bulk` ×10** + **`contract_handoff_token` ×1** (NEO-111) — satisfies chain terminal **`inventory_has_item`** once combat intro completes (token persists for chain step 4).
|
||||
- Chain step 1 requires **5** successful gathers **while chain step 1 is active** (objective counter; prior bag scrap does not substitute). Use [NEO-75](../manual-qa/NEO-75.md) anchor table (**alpha** +1, **beta** +2, **gamma** +3, **delta** +5).
|
||||
- Chain step 3 **`make_field_stim_mk0`** needs **2** `refined_plate_stock` + **1** scrap — plan extra **`refine_scrap_standard`** crafts after combat loot if bag is short on plates.
|
||||
- Defeat order for combat pocket is **flexible** (NEO-111); script asserts **0/3 → completed (3/3)** only.
|
||||
|
||||
**Main checklist spine (to expand in `docs/manual-qa/NEO-123.md`):**
|
||||
|
||||
1. Start server + Godot **F5**; confirm four quests **`not started`**.
|
||||
2. **Q** accept gather intro; gather until **`Intro: Salvage Run: completed`**.
|
||||
3. **Shift+Q** accept combat intro; defeat three prototype NPCs; confirm encounter + combat intro **`completed`** and bag contains **`contract_handoff_token`**.
|
||||
4. **Shift+Q** accept refine intro; craft **`refine_scrap_standard`**; confirm refine intro **`completed`**.
|
||||
5. **Shift+Q** accept operator chain; complete steps 1–4 (gather ×5, refine, stim craft, token check); confirm **`Operator Chain: completed`**.
|
||||
6. **Record** final quest HUD snapshot (all four **`completed`**).
|
||||
7. **Idempotency — Godot restart:** stop Godot; **F5** again **without** stopping server. Quest HUD still shows all four **`completed`**.
|
||||
8. **Idempotency — duplicate accepts:** **Q** on gather → **`denied — already_completed`**; **Shift+Q** → **`no eligible quest`** (or deny on ineligible pick); HUD unchanged from step 6.
|
||||
9. Regression pointers: [NEO-122](../manual-qa/NEO-122.md) component HUD, [NEO-111](../manual-qa/NEO-111.md) encounter loot, [NEO-75](../manual-qa/NEO-75.md) economy craft.
|
||||
|
||||
### 2. Client README (`client/README.md`)
|
||||
|
||||
Add **End-to-end onboarding quest loop (NEO-123)** section after [Quest progress + accept HUD (NEO-122)](../../client/README.md#quest-progress--accept-hud-neo-122), mirroring NEO-111 / NEO-75 capstone sections:
|
||||
|
||||
- One-line Epic 7 Slice 1 capstone goal.
|
||||
- Flow table: fresh restart → **Q** gather → **Shift+Q** combat → **Shift+Q** refine → **Shift+Q** chain → four-step chain objectives → idempotency.
|
||||
- Cross-links: NEO-112 catalog, NEO-118 wiring, NEO-119/120 HTTP, NEO-122 HUD, NEO-75 economy, NEO-111 combat token.
|
||||
- Pointer to **`docs/manual-qa/NEO-123.md`**.
|
||||
|
||||
### 3. Module / backlog alignment (on land)
|
||||
|
||||
When capstone QA passes:
|
||||
|
||||
- [E7M1-prototype-backlog.md](E7M1-prototype-backlog.md) — E7M1-12 AC checkboxes.
|
||||
- [E7_M1_QuestStateMachine.md](../decomposition/modules/E7_M1_QuestStateMachine.md) — module status **Ready**; Slice 1 client capstone note.
|
||||
- [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) — E7.M1 row: NEO-123 landed; Epic 7 Slice 1 client capstone complete.
|
||||
- [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) — E7.M1 **Ready** when all stories landed.
|
||||
|
||||
### 4. Integration fixes (conditional)
|
||||
|
||||
Run capstone QA on `main` (or branch with NEO-122 merged). If any step fails:
|
||||
|
||||
| Symptom | Likely fix surface |
|
||||
|---------|-------------------|
|
||||
| Quest HUD stale after gather/craft/defeat | `main.gd` refresh hook or `quest_hud_controller.gd` |
|
||||
| Chain step counter stuck | Server wiring (unlikely) — file issue; client displays GET only |
|
||||
| Token step not completing | Verify encounter loot + quest GET refresh on encounter **`completed`** (NEO-110 belt-and-suspenders) |
|
||||
| Shift+Q picks unexpected quest | Document vs fix picker — **out of scope** unless script order is impossible |
|
||||
|
||||
Document any fix in this plan’s **Implementation reconciliation** section and add regression note to manual QA.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `docs/plans/NEO-123-implementation-plan.md` | This plan. |
|
||||
| `docs/manual-qa/NEO-123.md` | Single-session four-quest capstone checklist with idempotency + regression steps. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `client/README.md` | **End-to-end onboarding quest loop (NEO-123)** section with flow table and cross-links. |
|
||||
| `docs/manual-qa/NEO-122.md` | Cross-link to NEO-123 capstone; note intro accept order defers to capstone script. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` | E7M1-12 AC checkboxes when capstone lands. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | Module status **Ready** + capstone landed note on completion. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row: NEO-123 landed; Slice 1 complete. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E7.M1 **Ready** when capstone lands. |
|
||||
|
||||
**Conditional (only if capstone QA fails):** `client/scripts/main.gd`, `client/scripts/quest_hud_controller.gd`, or related refresh wiring — minimal gap-fill; document paths in reconciliation.
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|----------------|
|
||||
| *(none planned)* | **Docs-primary scope** — server quest spine covered by **`QuestProgressApiTests`**, **`QuestAcceptApiTests`**, **`QuestObjectiveWiringTests`**, and content CI; client covered by NEO-122 GdUnit suites. Capstone proof is **`docs/manual-qa/NEO-123.md`** human QA. |
|
||||
|
||||
If integration fixes land, add or extend GdUnit only when the fix introduces new client parse/refresh behavior worth locking (follow [testing-expectations.md](../../.cursor/rules/testing-expectations.md) AAA layout).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Item | Agent recommendation | Status |
|
||||
|------|----------------------|--------|
|
||||
| **NEO-122 manual QA step 6 (Shift+Q → refine)** | Capstone adopts **catalog order** (combat before refine); update NEO-122 cross-link note, not NEO-122 checklist retroactively unless user asks. | **adopted** |
|
||||
| **Resource node depletion mid-session** | Script uses four anchors from NEO-75; fresh server gives enough total yield for intro + chain gathers. | **adopted** |
|
||||
| **Chain step 4 before combat loot** | Script orders combat intro before chain accept; token in bag before chain step 4. | **adopted** |
|
||||
| **Docs-only fails capstone** | Escalate to minimal client refresh fix in same story; record in reconciliation. | **deferred** |
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# NEO-124 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-124 |
|
||||
| **Title** | E7M2-01: QuestRewardBundle schema + quest catalog extension + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-124/e7m2-01-questrewardbundle-schema-quest-catalog-extension-ci |
|
||||
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-01** |
|
||||
| **Branch** | `NEO-124-e7m2-questrewardbundle-schema-quest-catalog-extension-ci` |
|
||||
| **Precursor** | [E7.M1](../decomposition/modules/E7_M1_QuestStateMachine.md) **Ready** ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)–[NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)) · [NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) **`MissionRewardSkillXpGrant`** prep · frozen item + skill catalogs |
|
||||
| **Pattern** | [NEO-112](NEO-112-implementation-plan.md) — extend row JSON Schema + catalog + `scripts/validate_content.py` gate |
|
||||
| **Blocks** | [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) (server catalog load), [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) (delivery store) |
|
||||
| **Client counterpart** | none — infrastructure-only ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-01--questrewardbundle-schema--quest-catalog-extension--ci)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Bundle array keys** | `item_grants` / `skill_xp_grants` (backlog prose) vs camelCase? | **`itemGrants` + `skillXpGrants`** — matches repo JSON (`fixedGrants`, `prerequisiteQuestIds`, `completionRewardBundle`). | **Adopted** — camelCase |
|
||||
| **Skill XP `sourceKind` in content** | Explicit `sourceKind: mission_reward` per row vs omit? | **Omit** — v1 Slice 2 is `mission_reward` only; CI checks target skill **`allowedXpSourceKinds`** includes `mission_reward`; apply path hardcodes via **`MissionRewardSkillXpGrant`** (NEO-43). | **Adopted** — omit from content |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / precedent):**
|
||||
|
||||
- **Item grant rows:** `$ref` existing [`reward-grant-row.schema.json`](../../content/schemas/reward-grant-row.schema.json) (`itemId` + `quantity`) — same shape as E5.M3 `fixedGrants`.
|
||||
- **Bundle required on all four quests:** CI gate enforces presence + exact freeze-table contents (not optional omission on prototype ids).
|
||||
- **Empty `itemGrants`:** omit property when no item rows (XP-only quests); schema allows optional `itemGrants` with `minItems: 0` when present.
|
||||
- **At least one grant:** bundle must contain ≥1 item or skill XP row (`skillXpGrants` non-empty for all four frozen quests per freeze table).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock **`completionRewardBundle`** content shape and CI validation on the four frozen E7.M1 quests before server load (NEO-125+).
|
||||
|
||||
**In scope (from Linear + [E7M2-01](E7M2-prototype-backlog.md#e7m2-01--questrewardbundle-schema--quest-catalog-extension--ci)):**
|
||||
|
||||
- `content/schemas/quest-reward-bundle.schema.json`, `content/schemas/quest-skill-xp-grant.schema.json`.
|
||||
- Extend [`quest-def.schema.json`](../../content/schemas/quest-def.schema.json) with optional **`completionRewardBundle`** (`$ref` bundle schema).
|
||||
- Update [`prototype_quests.json`](../../content/quests/prototype_quests.json) — bundles per freeze table (below).
|
||||
- [`scripts/validate_content.py`](../../scripts/validate_content.py): bundle schema validation via extended quest validator registry; item id cross-refs; skill id cross-refs; **`mission_reward`** allowlist alignment (skill row must list `mission_reward` in **`allowedXpSourceKinds`**); E7M2 prototype bundle freeze gate.
|
||||
- Designer note in [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) (camelCase array keys + CI line) + [`content/README.md`](../../content/README.md) E7 Slice 2 paragraph + [CT.M1](../decomposition/modules/CT_M1_ContentValidationPipeline.md) quest-bundle PR gate paragraph.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server loader, delivery store, router, HTTP, Godot (NEO-125+).
|
||||
- **`sourceKind`** field in content rows; unlock/flag grant kinds.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR gate validates quest JSON including optional completion bundles.
|
||||
- [x] All four prototype quests include **`completionRewardBundle`** matching freeze table.
|
||||
- [x] Invalid item/skill refs fail CI.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Schemas:** [`quest-skill-xp-grant.schema.json`](../../content/schemas/quest-skill-xp-grant.schema.json), [`quest-reward-bundle.schema.json`](../../content/schemas/quest-reward-bundle.schema.json); [`quest-def.schema.json`](../../content/schemas/quest-def.schema.json) extended with optional **`completionRewardBundle`**.
|
||||
- **Catalog:** [`prototype_quests.json`](../../content/quests/prototype_quests.json) — four **`completionRewardBundle`** rows per E7.M2 freeze table (`itemGrants` / `skillXpGrants` camelCase).
|
||||
- **CI:** `PROTOTYPE_E7M2_COMPLETION_BUNDLES`, presence/freeze/cross-ref gates in [`validate_content.py`](../../scripts/validate_content.py); **`mission_reward`** allowlist check via skill **`allowedXpSourceKinds`**.
|
||||
- **Docs:** E7.M2 module CI line + camelCase keys; `content/README.md` E7 Slice 2 paragraph; CT.M1 quest-bundle bullet; E7M2-01 backlog checkboxes; alignment register E7.M2 row.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. `quest-skill-xp-grant.schema.json`
|
||||
|
||||
Draft 2020-12 object; `additionalProperties: false`. Required: **`skillId`** (pattern `^[a-z][a-z0-9_]*$`), **`amount`** (integer ≥ 1). No **`sourceKind`** — apply path uses **`MissionRewardSkillXpGrant`** (NEO-43).
|
||||
|
||||
### 2. `quest-reward-bundle.schema.json`
|
||||
|
||||
Draft 2020-12 object; `additionalProperties: false`. Optional: **`itemGrants`** (array of `$ref` reward-grant-row), **`skillXpGrants`** (array of `$ref` quest-skill-xp-grant). At least one array must be present with ≥1 row (enforce in schema via `anyOf` / `minItems` or post-schema CI helper). Omit **`itemGrants`** when empty (XP-only quests).
|
||||
|
||||
### 3. Extend `quest-def.schema.json`
|
||||
|
||||
Add optional property **`completionRewardBundle`** → `$ref` quest-reward-bundle. Remaining required fields unchanged.
|
||||
|
||||
### 4. Extend `_quest_def_validator()` registry
|
||||
|
||||
Register **`quest-reward-bundle.schema.json`** and **`quest-skill-xp-grant.schema.json`** (and existing **`reward-grant-row.schema.json`**) in the Draft 2020-12 `$ref` registry alongside step/objective schemas.
|
||||
|
||||
### 5. Catalog — `content/quests/prototype_quests.json`
|
||||
|
||||
Add **`completionRewardBundle`** to each of the four rows:
|
||||
|
||||
| Quest id | `itemGrants` | `skillXpGrants` |
|
||||
|----------|--------------|-----------------|
|
||||
| `prototype_quest_gather_intro` | *(omit)* | `salvage` **25** |
|
||||
| `prototype_quest_refine_intro` | *(omit)* | `refine` **25** |
|
||||
| `prototype_quest_combat_intro` | *(omit)* | `salvage` **25** |
|
||||
| `prototype_quest_operator_chain` | `survey_drone_kit` ×1 | `salvage` **50** |
|
||||
|
||||
Example (gather intro):
|
||||
|
||||
```json
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||
}
|
||||
```
|
||||
|
||||
Example (operator chain):
|
||||
|
||||
```json
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||
}
|
||||
```
|
||||
|
||||
### 6. `validate_content.py` constants + gates
|
||||
|
||||
| Constant | Purpose |
|
||||
|----------|---------|
|
||||
| `PROTOTYPE_E7M2_COMPLETION_BUNDLES` | Map quest id → normalized `{ "itemGrants": [...], "skillXpGrants": [...] }` matching freeze table (keep in sync with E7.M2 module doc + future NEO-125 loader) |
|
||||
| `PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND` | `"mission_reward"` — used in cross-ref gate only |
|
||||
|
||||
**New validators (run after quest schema pass, alongside existing E7M1 gates):**
|
||||
|
||||
- **`_prototype_e7m2_completion_bundle_presence_gate`:** every id in `PROTOTYPE_E7M1_QUEST_IDS` must have **`completionRewardBundle`** object.
|
||||
- **`_prototype_e7m2_completion_bundle_freeze_gate`:** bundle contents must exactly match `PROTOTYPE_E7M2_COMPLETION_BUNDLES` (sorted grant rows for stable compare).
|
||||
- **`_prototype_e7m2_completion_bundle_cross_ref_gate`:** each **`itemGrants[].itemId`** ∈ frozen item ids; each **`skillXpGrants[].skillId`** ∈ frozen skill ids; each target skill’s **`allowedXpSourceKinds`** must include **`mission_reward`** (load skill rows from validated skill catalog or reuse existing skill id map if available in `main()` scope).
|
||||
|
||||
Extend module docstring (quest catalogs bullet) and startup schema file existence checks for new schema paths.
|
||||
|
||||
### 7. Docs
|
||||
|
||||
- **`E7_M2_RewardAndUnlockRouter.md`** — update freeze prose to **`itemGrants` / `skillXpGrants`** (camelCase); add CI enforcement line under prototype freeze.
|
||||
- **`content/README.md`** — **Prototype E7 Slice 2 — quest completion bundles (NEO-124)** paragraph: four quests require bundles; cross-ref rules; link E7M2 backlog + this plan.
|
||||
- **`CT_M1_ContentValidationPipeline.md`** — extend quest catalog bullet with completion bundle validation.
|
||||
- **`E7M2-prototype-backlog.md`** — E7M2-01 checkboxes when implementation completes.
|
||||
- **`documentation_and_implementation_alignment.md`** — E7.M2 row note NEO-124 catalog in progress / landed.
|
||||
|
||||
### 8. No server/C#/client changes in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/quest-skill-xp-grant.schema.json` | Single skill XP row on a quest completion bundle (`skillId`, `amount`). |
|
||||
| `content/schemas/quest-reward-bundle.schema.json` | Composite bundle (`itemGrants`, `skillXpGrants`) attached to `QuestDef`. |
|
||||
| `docs/plans/NEO-124-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `content/schemas/quest-def.schema.json` | Add optional **`completionRewardBundle`** property referencing bundle schema. |
|
||||
| `content/quests/prototype_quests.json` | Add **`completionRewardBundle`** to all four frozen quest rows per freeze table. |
|
||||
| `scripts/validate_content.py` | New schema paths; extended quest `$ref` registry; E7M2 bundle presence/freeze/cross-ref gates; docstring + schema existence checks. |
|
||||
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | camelCase grant keys + CI enforcement note under prototype freeze. |
|
||||
| `content/README.md` | E7 Slice 2 quest completion bundle paragraph (NEO-124). |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for quest completion bundles. |
|
||||
| `docs/plans/E7M2-prototype-backlog.md` | Mark E7M2-01 AC complete when shipped. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment row — NEO-124 catalog landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Target | Coverage |
|
||||
|--------|----------|
|
||||
| **No new automated test files** | Content-only slice; regression surface is **`scripts/validate_content.py`** invoked by PR gate (NEO-112 / NEO-100 precedent). |
|
||||
| **Manual verification** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — must pass with four bundles. Spot-check failures: typo **`itemId`**, unknown **`skillId`**, skill without **`mission_reward`** in **`allowedXpSourceKinds`**, missing **`completionRewardBundle`** on a quest row, wrong XP amount vs freeze table. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| Constants drift vs future NEO-125 C# loader | Sync comment on `PROTOTYPE_E7M2_COMPLETION_BUNDLES` pointing at E7.M2 module freeze + future server rules class. | **adopted** |
|
||||
| Backlog prose still says `item_grants` / `skill_xp_grants` | Update E7M2 backlog + E7.M2 module to camelCase when implementing (kickoff decision). | **adopted** |
|
||||
| Bundle compare stability (grant row order) | Normalize/sort grant rows in freeze gate before compare. | **adopted** |
|
||||
|
||||
None beyond the above.
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
# NEO-125 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-125 |
|
||||
| **Title** | E7M2-02: Server quest catalog load — reward bundle validation |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-125/e7m2-02-server-quest-catalog-load-reward-bundle-validation |
|
||||
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-02** |
|
||||
| **Branch** | `NEO-125-e7m2-server-quest-catalog-reward-bundle-validation` |
|
||||
| **Precursor** | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) **`Done`** — `completionRewardBundle` schemas, catalog rows, CI gates (**landed on `main`**) |
|
||||
| **Pattern** | [NEO-113](NEO-113-implementation-plan.md) — in-process C# gates mirroring `scripts/validate_content.py`; [NEO-101](NEO-101-implementation-plan.md) — multi-schema `$ref` registration (reward-table loader) |
|
||||
| **Blocks** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) (router apply) via [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) parallel path; catalog must load before delivery stories |
|
||||
| **Client counterpart** | none — infrastructure-only ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-02--server-quest-catalog-load-reward-bundle-validation)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** Linear goal (“CI parity”), E7M2 backlog in/out-of-scope, and [NEO-113](NEO-113-implementation-plan.md) / [NEO-124](NEO-124-implementation-plan.md) precedents settle:
|
||||
|
||||
- **Full E7M2 gate parity** on the server (presence + freeze table + item/skill cross-refs + **`mission_reward`** allowlist) — not cross-ref-only.
|
||||
- **Parse typed bundle rows** onto `QuestDefRow` now — backlog “extend catalog loader / registry types”; [NEO-127](NEO-127) router reads catalog without re-parsing JSON.
|
||||
- **Reuse `RewardGrantRow`** (Encounters) for **`itemGrants`** — same schema shape as E5.M3 / CI; add **`QuestSkillXpGrantRow`** + **`QuestRewardBundleRow`** under `Game/Quests/`.
|
||||
- **Separate `PrototypeE7M2QuestCatalogRules`** — mirrors E7M1 rules class + Python `PROTOTYPE_E7M2_*` constants.
|
||||
- **Host boot tests** — same kickoff default as NEO-113 (loader + `WebApplicationFactory`).
|
||||
|
||||
**Known gap on `main` after NEO-124:** quest catalog load throws `RefResolutionException` for `quest-reward-bundle.json` because bundle schemas are not registered in `CatalogSchemaRegistry` — fixing this is in scope.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Fail-fast startup validation of **`completionRewardBundle`** with CI parity; host refuses to listen when bundle refs or prototype freeze rules fail.
|
||||
|
||||
**In scope (from Linear + [E7M2-02](E7M2-prototype-backlog.md#e7m2-02--server-quest-catalog-load-reward-bundle-validation)):**
|
||||
|
||||
- Extend `server/NeonSprawl.Server/Game/Quests/` loader, DTOs, and prototype rules.
|
||||
- Register bundle-related JSON Schemas before row validation.
|
||||
- Cross-check **`itemGrants[].itemId`** against the item catalog and **`skillXpGrants[].skillId`** against the skill catalog + **`allowedXpSourceKinds`** (must include **`mission_reward`**).
|
||||
- Unit tests (AAA) for happy path and broken bundle refs; host boot still resolves catalog.
|
||||
- `server/README.md` quest-catalog section update.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Delivery apply, **`IRewardDeliveryStore`**, router, HTTP projection changes (NEO-126+).
|
||||
- Godot client.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Host exits on invalid bundle refs at startup.
|
||||
- [x] `dotnet test` covers loader gates.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **DTOs:** `QuestSkillXpGrantRow`, `QuestRewardBundleRow`; optional **`CompletionRewardBundle`** on **`QuestDefRow`**.
|
||||
- **Rules:** `PrototypeE7M2QuestCatalogRules` — presence, freeze-table, cross-ref + **`mission_reward`** allowlist gates synced to `validate_content.py`.
|
||||
- **Loader:** registers bundle-related JSON Schemas; parses **`completionRewardBundle`**; accepts **`SkillDefinitionCatalog.ById`** from DI.
|
||||
- **Tests:** 25 AAA tests in **`QuestDefinitionCatalogLoaderTests`** (5 E7M2 loader cases + updated prototype fixture with bundles); **`PrototypeE7M2QuestCatalogRulesTests`** (4 direct cross-ref / allowlist cases).
|
||||
- **Docs:** `server/README.md` quest catalog E7M2 paragraph; E7M2-02 backlog AC checked.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Bundle DTOs (`Game/Quests/`)
|
||||
|
||||
- **`QuestSkillXpGrantRow`** — `SkillId`, `Amount` (record or sealed class, same style as `QuestObjectiveDefRow`).
|
||||
- **`QuestRewardBundleRow`** — `ItemGrants` (`IReadOnlyList<RewardGrantRow>` from `Game.Encounters`), `SkillXpGrants` (`IReadOnlyList<QuestSkillXpGrantRow>`).
|
||||
- Extend **`QuestDefRow`** with optional **`CompletionRewardBundle`** (`QuestRewardBundleRow?`).
|
||||
|
||||
### 2. `PrototypeE7M2QuestCatalogRules`
|
||||
|
||||
New static class synced to `scripts/validate_content.py`:
|
||||
|
||||
| Constant / helper | Python source |
|
||||
|-------------------|---------------|
|
||||
| `MissionRewardSourceKind` = `"mission_reward"` | `PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND` |
|
||||
| `ExpectedCompletionBundles` | `PROTOTYPE_E7M2_COMPLETION_BUNDLES` (normalized item + skill grant rows per quest id) |
|
||||
| `TryGetCompletionBundlePresenceError(rowsById)` | `_prototype_e7m2_completion_bundle_presence_gate` |
|
||||
| `TryGetCompletionBundleFreezeError(rowsById)` | `_prototype_e7m2_completion_bundle_freeze_gate` (sort grant rows before compare) |
|
||||
| `TryGetCompletionBundleCrossRefError(rowsById, knownItemIds, skillDefsById)` | `_prototype_e7m2_completion_bundle_cross_ref_gate` |
|
||||
|
||||
Presence/freeze gates apply only to ids in **`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`** (four frozen quests).
|
||||
|
||||
### 3. Schema registration (`QuestDefinitionCatalogLoader`)
|
||||
|
||||
Before evaluating `quest-def.schema.json`, register (dependency order, same as CI `$ref` registry):
|
||||
|
||||
1. `reward-grant-row.schema.json`
|
||||
2. `quest-skill-xp-grant.schema.json`
|
||||
3. `quest-reward-bundle.schema.json`
|
||||
4. Existing objective → step → def chain
|
||||
|
||||
Add path resolvers on **`QuestCatalogPathResolution`** (sibling `content/schemas/` defaults, optional `ContentPathsOptions` overrides if needed for symmetry — can derive from quests directory like step schema).
|
||||
|
||||
Extend **`EnsureQuestSchemasLoaded`** signature to accept bundle schema paths; validate schema files exist at load start.
|
||||
|
||||
### 4. Loader parse + gates
|
||||
|
||||
- **`ParseRow`**: when `completionRewardBundle` present, parse into **`QuestRewardBundleRow`** (omit empty **`itemGrants`** arrays).
|
||||
- After existing E7M1 gates, run E7M2 rules in order: presence → freeze → cross-ref.
|
||||
- Extend **`Load(...)`** signature with **`IReadOnlyDictionary<string, SkillDefRow> skillDefsById`** (or `SkillDefinitionCatalog` + extract `.ById` in DI factory).
|
||||
|
||||
### 5. DI (`QuestCatalogServiceCollectionExtensions`)
|
||||
|
||||
- Inject **`SkillDefinitionCatalog`** alongside item / recipe / encounter catalogs.
|
||||
- Pass **`skillCatalog.ById`** into loader (skills already register before quests in **`Program.cs`**).
|
||||
|
||||
### 6. Tests (`QuestDefinitionCatalogLoaderTests`)
|
||||
|
||||
- Update **`ValidPrototypeCatalogJson`** with **`completionRewardBundle`** on all four quests (match repo / freeze table).
|
||||
- **`CreateTempContentLayout`**: copy bundle + grant-row schemas from repo (add helpers on **`QuestCatalogTestPaths`**).
|
||||
- Extend **`LoadCatalog`** helper with frozen skill defs map (reuse repo skill catalog discovery or inline salvage/refine rows with **`mission_reward`** in **`AllowedXpSourceKinds`**).
|
||||
- New negative tests (AAA):
|
||||
- Missing **`completionRewardBundle`** on a frozen quest id.
|
||||
- Wrong freeze-table XP amount.
|
||||
- Unknown **`itemId`** in bundle.
|
||||
- Unknown **`skillId`** in bundle.
|
||||
- Skill missing **`mission_reward`** in **`allowedXpSourceKinds`**.
|
||||
- Existing **`Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile`** and **`Host_ShouldResolveQuestCatalogFromDi`** must pass again.
|
||||
|
||||
### 7. Docs
|
||||
|
||||
- **`server/README.md`** — extend quest catalog paragraph: bundle schema `$ref`s, E7M2 presence/freeze/cross-ref gates, skill catalog dependency.
|
||||
- **`E7M2-prototype-backlog.md`** — check E7M2-02 AC boxes when shipped.
|
||||
- **`documentation_and_implementation_alignment.md`** — E7.M2 row note server load (NEO-125).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs` | Skill XP row on a completion bundle. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs` | Composite bundle DTO (`ItemGrants`, `SkillXpGrants`). |
|
||||
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs` | E7M2 prototype gates + constants synced to `validate_content.py`. |
|
||||
| `docs/plans/NEO-125-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Add optional **`CompletionRewardBundle`** property. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Register bundle schemas; parse bundle; run E7M2 gates; accept skill defs. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs` | Resolve paths for bundle / skill-xp-grant / reward-grant-row schemas. |
|
||||
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Pass **`SkillDefinitionCatalog`** into loader. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Repo discovery helpers for new schema files. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | Bundle fixtures, skill map, new failure tests; fix regressions from NEO-124 content. |
|
||||
| `server/README.md` | Document E7M2 bundle validation at quest catalog load. |
|
||||
| `docs/plans/E7M2-prototype-backlog.md` | Mark E7M2-02 AC when complete. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — server bundle load landed. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Target | Coverage |
|
||||
|--------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/PrototypeE7M2QuestCatalogRulesTests.cs` | Direct unit tests for **`TryGetCompletionBundleCrossRefError`** (item/skill catalog + allowlist paths). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | Happy path (repo + temp fixture with bundles); E7M2 presence, freeze, allowlist deny; host DI resolve. |
|
||||
| **No other new test files** | Loader + rules classes per NEO-113 precedent. |
|
||||
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter QuestDefinitionCatalogLoaderTests`; boot dev server and confirm quest catalog Information log after NEO-124 content. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| Constants drift vs `validate_content.py` | Sync comments on `PrototypeE7M2QuestCatalogRules` → `PROTOTYPE_E7M2_*`; same pattern as E7M1 rules. | **adopted** |
|
||||
| `QuestDefRow` bundle null on future non-prototype quests | Optional property; E7M2 gates only enforce bundles on four frozen ids. | **adopted** |
|
||||
| Cross-namespace `RewardGrantRow` reuse | Reuse Encounters struct — identical JSON shape; avoids duplicate DTO. | **adopted** |
|
||||
| HTTP quest-definitions omits bundle | Out of scope; NEO-115 DTOs unchanged until a client story needs bundle preview. | **deferred** |
|
||||
|
||||
None beyond the above.
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# NEO-126 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-126 |
|
||||
| **Title** | E7M2-03: Reward delivery store (`IRewardDeliveryStore`) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-126/e7m2-03-reward-delivery-store-irewarddeliverystore |
|
||||
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-03** |
|
||||
| **Branch** | `NEO-126-e7m2-reward-delivery-store` |
|
||||
| **Precursor** | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) **`Done`** — bundle schemas + catalog rows · [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) **`Done`** — server catalog load + E7M2 gates |
|
||||
| **Pattern** | [NEO-107](NEO-107-implementation-plan.md) — `IEncounterCompleteEventStore` / `InMemoryEncounterCompleteEventStore` idempotent `TryRecord` + `TryGet` |
|
||||
| **Blocks** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) (`RewardRouterOperations` apply path) |
|
||||
| **Client counterpart** | none — infrastructure-only ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-03--reward-delivery-store-irewarddeliverystore)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Postgres persistence** | Implement NEO-29-style Postgres split now or in-memory only? | **In-memory only** — mirror `IEncounterCompleteEventStore`; NEO-126 AC is store idempotency + readable summary, not cross-restart durability; Postgres deferred until a story needs persisted delivery rows. | **Adopted** — in-memory only |
|
||||
|
||||
**Additional defaults (no kickoff question — settled by backlog / precedent):**
|
||||
|
||||
- **Idempotency key:** `{normalizedPlayerId}:quest_complete:{normalizedQuestId}` per [E7M2 kickoff decisions](E7M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults) and [E7.M2 module](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md#prototype-slice-2-freeze-e7m2-01).
|
||||
- **Namespace:** `Game/Rewards/` — delivery is cross-cutting (quest completion v1; encounter consumer later); keeps `Game/Quests/` for state machine only.
|
||||
- **Grant summary rows:** dedicated `RewardItemGrantApplied` + `RewardSkillXpGrantApplied` on the event (do not reuse `EncounterGrantApplied` — quest bundles include skill XP lines).
|
||||
- **Store key:** composite `playerId\0questId` via `RewardDeliveryIds.MakeStoreKey` for `TryGet(playerId, questId)`; event carries explicit **`IdempotencyKey`** string for router audit.
|
||||
- **Dev `TryClear`:** optional on `InMemoryRewardDeliveryStore` only (Bruno / test reset parity with `InMemoryEncounterCompleteEventStore`).
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Durable idempotency record for **`RewardDeliveryEvent`** per player + quest completion.
|
||||
|
||||
**In scope (from Linear + [E7M2-03](E7M2-prototype-backlog.md#e7m2-03--reward-delivery-store-irewarddeliverystore)):**
|
||||
|
||||
- `IRewardDeliveryStore`, in-memory implementation.
|
||||
- Idempotency key **`{playerId}:quest_complete:{questId}`**; store granted item + skill XP summary for audit/read projection.
|
||||
- Unit tests (AAA): first delivery records; replay returns existing event without double-apply (`TryRecord` → `false`).
|
||||
- `server/README.md` store section.
|
||||
- DI registration in `Program.cs`.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Router apply logic ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) E7M2-04).
|
||||
- Quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) E7M2-05).
|
||||
- HTTP `completionRewardSummary` projection ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06).
|
||||
- Postgres migration / `PostgresRewardDeliveryStore` (deferred per kickoff answer).
|
||||
- Godot client.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] `TryRecord` idempotent on same key.
|
||||
- [x] Store readable summary for HTTP projection.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Types:** `RewardDeliveryEvent`, `RewardItemGrantApplied`, `RewardSkillXpGrantApplied`, `RewardDeliveryIds` in `Game/Rewards/`.
|
||||
- **Store:** `IRewardDeliveryStore` + `InMemoryRewardDeliveryStore` (`TryRecord`, `TryGet`, dev `TryClear`).
|
||||
- **DI:** `AddRewardDeliveryStore()` in `Program.cs` — in-memory singleton only (Postgres deferred per kickoff).
|
||||
- **Tests:** 5 AAA tests in `InMemoryRewardDeliveryStoreTests` (idempotency incl. mixed-case replay, fail-closed, grant summary, host DI resolve).
|
||||
- **Docs:** `server/README.md` reward delivery store section; E7M2-03 backlog AC checked; alignment register updated.
|
||||
- **Bruno:** `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru` (host boot smoke; pre-commit gate for `Program.cs`).
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Event + grant summary types (`Game/Rewards/`)
|
||||
|
||||
**`RewardDeliveryEvent`** (readonly record struct):
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
| **`PlayerId`** | Normalized player id. |
|
||||
| **`QuestId`** | Normalized quest id. |
|
||||
| **`DeliveredAt`** | UTC timestamp at record commit (from router/`TimeProvider` in NEO-127). |
|
||||
| **`GrantedItems`** | Snapshot of applied item rows (`RewardItemGrantApplied`: `ItemId`, `Quantity`). |
|
||||
| **`GrantedSkillXp`** | Snapshot of applied skill XP rows (`RewardSkillXpGrantApplied`: `SkillId`, `Amount`). |
|
||||
| **`IdempotencyKey`** | Stable **`{playerId}:quest_complete:{questId}`**. |
|
||||
|
||||
Defensive copy on `TryRecord` (`.ToArray()` on lists) — same as `InMemoryEncounterCompleteEventStore`.
|
||||
|
||||
### 2. `RewardDeliveryIds`
|
||||
|
||||
Static helper (mirror `QuestProgressIds` / `EncounterProgressIds`):
|
||||
|
||||
- `NormalizePlayerId` / `NormalizeQuestId` — trim + lowercase; empty on whitespace.
|
||||
- `MakeIdempotencyKey(playerId, questId)` → `{p}:quest_complete:{q}` or empty when invalid.
|
||||
- `MakeStoreKey(playerId, questId)` → `{p}\0{q}` for dictionary lookup.
|
||||
|
||||
### 3. `IRewardDeliveryStore`
|
||||
|
||||
```csharp
|
||||
bool TryRecord(RewardDeliveryEvent deliveryEvent);
|
||||
bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent);
|
||||
```
|
||||
|
||||
First `TryRecord` returns `true`; replays for the same player+quest return `false`. Invalid ids fail closed (`false`).
|
||||
|
||||
### 4. `InMemoryRewardDeliveryStore`
|
||||
|
||||
- `ConcurrentDictionary<string, RewardDeliveryEvent>` + per-key locks — same threading model as `InMemoryEncounterCompleteEventStore`.
|
||||
- `TryClear(playerId, questId)` on concrete type only (dev fixture).
|
||||
|
||||
### 5. DI
|
||||
|
||||
**`RewardDeliveryServiceCollectionExtensions.AddRewardDeliveryStore`:**
|
||||
|
||||
- `services.AddSingleton<IRewardDeliveryStore, InMemoryRewardDeliveryStore>();`
|
||||
- Call from **`Program.cs`** after quest catalog / quest state registration.
|
||||
|
||||
No connection-string branch until Postgres story.
|
||||
|
||||
### 6. Tests (`InMemoryRewardDeliveryStoreTests`)
|
||||
|
||||
Mirror `InMemoryEncounterCompleteEventStoreTests`:
|
||||
|
||||
- **`TryRecord_ShouldBeIdempotent_AtStoreLayer`** — first `true`, second `false`; `TryGet` returns original `DeliveredAt` + grant snapshots.
|
||||
- **`TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty`**
|
||||
- **`TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty`**
|
||||
- **`TryGet_ShouldExposeReadableGrantSummary`** — item + skill XP rows preserved in order for future HTTP mapper (NEO-129).
|
||||
|
||||
Optional host smoke: assert `WebApplicationFactory` resolves `IRewardDeliveryStore` as `InMemoryRewardDeliveryStore` (same pattern as `EncounterDefinitionRegistryTests`).
|
||||
|
||||
### 7. Docs
|
||||
|
||||
- **`server/README.md`** — new **Reward delivery store (NEO-126)** section: interface, event fields, idempotency key, in-memory prototype policy, pointer to NEO-127 router consumer.
|
||||
- **`documentation_and_implementation_alignment.md`** — E7.M2 row note: delivery store landed (NEO-126).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardItemGrantApplied.cs` | Item grant snapshot on a delivery event. |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardSkillXpGrantApplied.cs` | Skill XP grant snapshot on a delivery event. |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryEvent.cs` | Idempotent delivery payload (quest completion v1). |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryIds.cs` | Id normalization, store key, idempotency key helper. |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/IRewardDeliveryStore.cs` | Store contract (`TryRecord`, `TryGet`). |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/InMemoryRewardDeliveryStore.cs` | Thread-safe in-memory implementation + dev `TryClear`. |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryServiceCollectionExtensions.cs` | DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | AAA idempotency + fail-closed + summary tests. |
|
||||
| `docs/plans/NEO-126-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register `AddRewardDeliveryStore()`. |
|
||||
| `server/README.md` | Document `IRewardDeliveryStore`, event shape, idempotency key. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — delivery store (NEO-126). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Target | Coverage |
|
||||
|--------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Rewards/InMemoryRewardDeliveryStoreTests.cs` | Idempotent `TryRecord`; fail-closed empty ids; `TryGet` returns item + skill XP summary suitable for HTTP projection. |
|
||||
| **Optional** — extend an existing host/DI test file | `IRewardDeliveryStore` resolves as `InMemoryRewardDeliveryStore` from `WebApplicationFactory`. |
|
||||
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter InMemoryRewardDeliveryStoreTests`; boot dev server — no behavior change until NEO-127 wires router. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| Postgres deferred vs backlog “optional migration” wording | Kickoff adopted in-memory only; add Postgres when restart durability is required (likely before production or a dedicated persistence story). | **adopted** |
|
||||
| Encounter deliveries reuse same store | Out of scope — quest completion keys only in NEO-126; encounter path stays on `IEncounterCompleteEventStore` until a follow-on unifies consumers. | **adopted** |
|
||||
| Empty grant arrays on event | Allow empty lists when bundle has only one grant kind; router (NEO-127) populates from apply outcome. | **adopted** |
|
||||
| HTTP DTO mapping | NEO-129 maps `GrantedItems` / `GrantedSkillXp` → `completionRewardSummary`; no API changes in NEO-126. | **deferred** |
|
||||
|
||||
None beyond the above.
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
# NEO-127 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-127 |
|
||||
| **Title** | E7M2-04: RewardRouterOperations (apply QuestRewardBundle) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-127/e7m2-04-rewardrouteroperations-apply-questrewardbundle |
|
||||
| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-04** |
|
||||
| **Branch** | `NEO-127-e7m2-reward-router-operations` |
|
||||
| **Precursor** | [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) **`Done`** — catalog load + bundle validation · [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) **`Done`** — `IRewardDeliveryStore` + `RewardDeliveryEvent` |
|
||||
| **Pattern** | [NEO-105](NEO-105-implementation-plan.md) — `EncounterCompletionOperations` simulate-all + sequential apply + compensating rollback; [NEO-107](NEO-107-implementation-plan.md) — store `TryRecord` after grants commit |
|
||||
| **Blocks** | [NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) (`QuestStateOperations.TryMarkComplete` wiring) |
|
||||
| **Client counterpart** | none — server engine ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
**No clarifications needed.** Linear AC, [E7M2-04 backlog](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle), [E7M2 kickoff decisions](E7M2-prototype-backlog.md#kickoff-decisions-decomposition-defaults), and landed NEO-105/NEO-126 code settle:
|
||||
|
||||
- **Namespace `Game/Rewards/`** — co-locate router with `IRewardDeliveryStore` (NEO-126); bundle DTOs stay on `QuestDefRow` in `Game/Quests/` (NEO-125).
|
||||
- **Grant order** — item grants first, skill XP second; compensating **item** rollback on inventory or skill-apply failure (NEO-105 precedent). Prototype bundles have ≤1 skill row; no XP rollback helper exists.
|
||||
- **Skill XP path** — call **`SkillProgressionGrantOperations.TryApplyGrant`** with **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** (not the fire-and-forget **`MissionRewardSkillXpGrant`** wrapper) so deny envelopes map to router reason codes.
|
||||
- **Idempotent replay** — **`IRewardDeliveryStore.TryGet`** before apply; return success with existing **`RewardDeliveryEvent`** without re-granting (backlog “idempotent replay via store”).
|
||||
- **Empty bundle** — null or both-empty grant lists → success with empty snapshots and **`TryRecord`** (NEO-126 allows empty arrays; CI guarantees all four prototype quests have bundles).
|
||||
- **Store race** — if grants succeed but **`TryRecord`** returns `false`, **rollback this call's item + skill XP grants** and return the winner's store event (mirror **`EncounterCompletionOperations`** when **`TryMarkCompleted`** loses); do not retain duplicate grants on the loser.
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Server-internal apply for quest **`completionRewardBundle`** rows (items + skill XP) with structured outcomes and durable idempotency via **`IRewardDeliveryStore`**.
|
||||
|
||||
**In scope (from Linear + [E7M2-04](E7M2-prototype-backlog.md#e7m2-04--rewardrouteroperations-apply-questrewardbundle)):**
|
||||
|
||||
- **`RewardRouterOperations.TryDeliverQuestCompletion`** in `server/NeonSprawl.Server/Game/Rewards/`.
|
||||
- Item grants via **`PlayerInventoryOperations`** (simulate-all pre-flight, sequential apply, compensating rollback).
|
||||
- Skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind.
|
||||
- **`RewardDeliveryReasonCodes`** + **`RewardDeliveryResult`** (success, deny reason, grant snapshots, **`RewardDeliveryEvent?`**).
|
||||
- Unit + integration tests (AAA): happy path (skill-only + item+skill bundles), inventory deny, idempotent store replay, apply-phase item rollback, skill deny passthrough.
|
||||
- `server/README.md` router section.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Quest state transition hooks ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128) E7M2-05).
|
||||
- HTTP DTOs / **`completionRewardSummary`** ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129) E7M2-06).
|
||||
- E9.M1 telemetry hook comments ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130) E7M2-07).
|
||||
- Godot client, Bruno (optional on NEO-128).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Bundle rows apply atomically where possible; compensating rollback on partial failure before store record.
|
||||
- [x] Skill XP uses **`mission_reward`** source kind only.
|
||||
|
||||
## Implementation reconciliation (shipped)
|
||||
|
||||
- **Operations:** `RewardRouterOperations.TryDeliverQuestCompletion` — item pre-flight + apply, skill XP via `TryApplyGrant` (`mission_reward`), compensating item rollback + per-grant perk tracking + **`TryRevertAppliedSkillGrants`** on **`TryRecord`** race loss, `IRewardDeliveryStore.TryRecord`.
|
||||
- **Types:** `RewardDeliveryResult`, `RewardDeliveryReasonCodes`.
|
||||
- **Tests:** 14 AAA tests in `RewardRouterOperationsTests` (…, TryRecord race rollback, concurrent race no double-grant, perk revert on simulated race loss, concurrent level-up winner perk preserved).
|
||||
- **Docs:** `server/README.md` reward router section; E7.M2 module anchor + alignment register updated.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### 1. Result + reason types (`Game/Rewards/`)
|
||||
|
||||
**`RewardDeliveryReasonCodes`** — stable strings for router denies:
|
||||
|
||||
| Code | When |
|
||||
|------|------|
|
||||
| **`already_delivered`** | **`IRewardDeliveryStore.TryGet`** hit before apply (idempotent replay — success path, not a deny). |
|
||||
| **`inventory_store_missing`** | Inventory snapshot unavailable. |
|
||||
| **`inventory_full`**, **`invalid_item`**, **`invalid_quantity`** | Passthrough from **`PlayerInventoryReasonCodes`** via **`PlayerInventoryOperations`**. |
|
||||
| **`unknown_skill`**, **`invalid_amount`**, **`source_kind_not_allowed`** | Passthrough from **`SkillProgressionSnapshotApi`** reason constants via **`TryApplyGrant`**. |
|
||||
| **`progression_store_missing`** | **`SkillProgressionGrantApplyKind.ProgressionStoreMissing`**. |
|
||||
|
||||
**`RewardDeliveryResult`** — readonly record:
|
||||
|
||||
| Field | Role |
|
||||
|-------|------|
|
||||
| **`Success`** | Grants committed (includes idempotent replay). |
|
||||
| **`ReasonCode`** | Set when **`Success`** is false. |
|
||||
| **`GrantedItems`** | Applied item snapshot (`RewardItemGrantApplied`). |
|
||||
| **`GrantedSkillXp`** | Applied skill XP snapshot (`RewardSkillXpGrantApplied`). |
|
||||
| **`DeliveryEvent`** | Populated on success (fresh or replay). |
|
||||
| **`DeliveredAt`** | From event on success. |
|
||||
|
||||
### 2. `RewardRouterOperations.TryDeliverQuestCompletion`
|
||||
|
||||
Signature (static ops, mirror encounter completion):
|
||||
|
||||
```csharp
|
||||
public static RewardDeliveryResult TryDeliverQuestCompletion(
|
||||
string playerId,
|
||||
string questId,
|
||||
QuestRewardBundleRow? bundle,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore skillProgressionStore,
|
||||
ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine,
|
||||
IPlayerPerkStateStore perkStore,
|
||||
IRewardDeliveryStore deliveryStore,
|
||||
TimeProvider timeProvider)
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. **Normalize ids** via **`RewardDeliveryIds`**; fail closed on empty player/quest id (deny with appropriate code or empty reason — match store fail-closed).
|
||||
2. **Idempotent replay** — if **`deliveryStore.TryGet`**, return **`Success: true`** with existing event and grant snapshots (no inventory/skill mutation).
|
||||
3. **Resolve grants** — treat null **`bundle`** as empty item + skill lists.
|
||||
4. **Item pre-flight** — clone inventory snapshot; **`TrySimulateAddStack`** for each **`bundle.ItemGrants`** row in catalog order.
|
||||
5. **Item apply** — foreach row **`TryAddStack`**; accumulate **`RewardItemGrantApplied`**; on deny/store-missing, return deny **without** skill apply.
|
||||
6. **Skill XP apply** — foreach **`bundle.SkillXpGrants`** row, **`TryApplyGrant(playerId, skillId, amount, MissionRewardSourceKind, …)`**:
|
||||
- On **`Denied`**, **`CompensatingRemoveItems`** for applied item rows and return mapped **`ReasonCode`** from **`outcome.Response!.ReasonCode`**.
|
||||
- On **`ProgressionStoreMissing`**, compensating item rollback + deny.
|
||||
- On **`Granted`**, append **`RewardSkillXpGrantApplied`**.
|
||||
7. **Record** — build **`RewardDeliveryEvent`** with **`DeliveredAt = timeProvider.GetUtcNow()`**, **`IdempotencyKey = RewardDeliveryIds.MakeIdempotencyKey(...)`**, grant snapshots.
|
||||
8. **`deliveryStore.TryRecord`** — if `true`, return fresh success; if `false` (race), **compensating rollback** of this call's item + skill XP grants, then return **`TryGet`** winner event with **`already_delivered`** (or deny if store miss).
|
||||
|
||||
**Compensating item rollback** — private helper mirroring **`EncounterCompletionOperations.CompensatingRemoveGrants`**: reverse-order **`TryRemoveStack`** for applied item rows only (no skill XP undo).
|
||||
|
||||
**Compensating skill progression rollback** — track perks unlocked per skill grant (`CollectPerksUnlockedSince`); on **`TryRecord`** race loss, **`TryRevertAppliedSkillGrants`** reverses each applied skill XP row via negative delta and removes only perks this call unlocked, then **`ReevaluateAfterLevelUp`** at post-rollback levels re-applies path-auto perks the winner's XP still qualifies for (avoids full snapshot restore clobbering a concurrent winner).
|
||||
|
||||
### 3. Skill XP source kind enforcement
|
||||
|
||||
Always pass **`MissionRewardSkillXpConstants.MissionRewardSourceKind`** at apply time — content rows do not carry **`sourceKind`** (NEO-124/NEO-43). Startup catalog gates already require skills to allow **`mission_reward`**; router still maps **`source_kind_not_allowed`** if a deny occurs.
|
||||
|
||||
### 4. Tests (`RewardRouterOperationsTests`)
|
||||
|
||||
Use **`InMemoryWebApplicationFactory`** + resolved DI (same pattern as **`EncounterCompletionOperationsTests`**):
|
||||
|
||||
| Test | Coverage |
|
||||
|------|----------|
|
||||
| **`TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly`** | **`prototype_quest_gather_intro`**-shaped bundle → salvage +25; store recorded; replay via second call returns same event without double XP. |
|
||||
| **`TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle`** | **`survey_drone_kit` ×1** + salvage +50; inventory + progression assertions. |
|
||||
| **`TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord`** | Fill bag; deny **`inventory_full`**; **`TryGet`** false. |
|
||||
| **`TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied`** | Item grant succeeds then skill deny (inject invalid amount or use test-only bundle row) → item removed; no store record. |
|
||||
| **`TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered`** | Pre-seed **`IRewardDeliveryStore`**; call router → success, no inventory/XP change. |
|
||||
| **`TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind`** | Assert grant succeeds for catalog skill; optional negative with skill lacking allowlist (unit-level bundle row). |
|
||||
|
||||
Optional host smoke: resolve **`RewardRouterOperations`** is static — assert factory provides all injected stores/registries used in one happy-path call.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
- **`server/README.md`** — new **Reward router (NEO-127)** section: operation entry point, reason codes, apply order, idempotency + store interaction, pointer to NEO-128 wiring.
|
||||
- **`documentation_and_implementation_alignment.md`** — E7.M2 row: router apply landed (NEO-127).
|
||||
- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor for **`RewardRouterOperations`**.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryReasonCodes.cs` | Stable router deny reason strings. |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardDeliveryResult.cs` | Structured outcome + grant snapshots + optional event. |
|
||||
| `server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs` | `TryDeliverQuestCompletion` apply + rollback + store record. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | AAA happy path, deny, rollback, idempotent replay tests. |
|
||||
| `docs/plans/NEO-127-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/README.md` | Document `RewardRouterOperations`, reason codes, idempotency flow. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — router apply (NEO-127). |
|
||||
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Add NEO-127 implementation anchor after delivery store bullet. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Target | Coverage |
|
||||
|--------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Rewards/RewardRouterOperationsTests.cs` | Skill-only + item+skill happy paths; `inventory_full` fail-closed; item compensating rollback on skill deny; store idempotent replay; `mission_reward` apply path. |
|
||||
| **Optional** — extend existing host/DI test | Factory resolves all router dependencies (smoke only). |
|
||||
| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests`; dev server boot unchanged until NEO-128 wires quest complete. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question or risk | Agent recommendation | Status |
|
||||
|------------------|----------------------|--------|
|
||||
| Multi-row skill XP partial apply | Prototype bundles have ≤1 skill row; implement sequential apply with item rollback on any skill deny; defer XP compensating rollback unless a future story requires multi-row bundles. | **adopted** |
|
||||
| `MissionRewardSkillXpGrant` vs `TryApplyGrant` | Use **`TryApplyGrant`** directly for deny envelopes; keep **`MissionRewardSkillXpGrant`** as HTTP/convenience wrapper only. | **adopted** |
|
||||
| Null bundle on future quests | Treat as empty grants + record delivery for idempotency; NEO-128 may skip router call when bundle absent — both safe. | **adopted** |
|
||||
| Quest-state rollback on deny | NEO-128 scope per E7M2 fail-closed decision; NEO-127 returns structured deny only. | **deferred** |
|
||||
|
||||
None beyond the above.
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Code review — NEO-119 (E7M1-08)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-119-get-quest-progress` vs `origin/main` — commits `78e5de3` … `9e4c52f`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-119 adds **`GET /game/players/{id}/quest-progress`** — a versioned v1 snapshot merging **`IQuestDefinitionRegistry`** catalog rows with **`IPlayerQuestStateStore`** progress (missing row → `not_started`). The handler mirrors **`EncounterProgressApi`**: trim id, position-store gate → **404**, then JSON. Before snapshot build it best-effort calls **`QuestObjectiveWiring.TryRefreshInventoryProgressForPlayer`** (inventory refresh + step completion sweep), adopting the kickoff side-effecting GET decision for HUD polling. Seven AAA integration tests cover 404 gates, default `not_started`, active partial counters, completed gather/chain terminal index, and GET-triggered chain completion with a pre-held token. Bruno default-state smoke, **`server/README.md`**, plan reconciliation, and E7.M1 decomposition docs are updated. Server-only slice — client HUD correctly deferred to NEO-122. Risk is low; no blocking correctness or contract issues found.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-119-implementation-plan.md` | **Matches** — kickoff GET refresh adopted; acceptance checklist checked; reconciliation section accurate. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-08) | **Matches** — acceptance checkboxes checked; client counterpart NEO-122 noted. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-119 GET bullet + README/Bruno links. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-08 / NEO-119 landed note; register stays **Planned** until E7M1-09+ (correct). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-119 note appended. |
|
||||
| `server/README.md` | **Matches** — route, envelope, side-effecting GET note, sample JSON. |
|
||||
| Full-stack epic decomposition | **Matches** — server-only story; plan explicitly defers Godot to NEO-122; does not claim prototype slice complete. |
|
||||
|
||||
**Register / tracking:** E7.M1 row correctly remains **Planned** until accept HTTP (NEO-120) and client capstone (NEO-123); no further register edits required for this merge.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Assert response array order explicitly** — `GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress` and the Bruno default test compare **sorted** quest ids. The plan and DTO doc promise catalog **`id`** ordinal order (same as **`GetDefinitionsInIdOrder()`**). For the fixed four-quest prototype, sorted vs sequence is equivalent today, but asserting sequence (as in `QuestDefinitionRegistryTests`) would lock the contract if ids ever stop sorting identically to registry order.~~ **Done.** Test asserts `GetDefinitionsInIdOrder()` sequence; Bruno compares response order without sort.
|
||||
|
||||
2. ~~**Side-effecting GET test store read-back** — `GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet` asserts HTTP `completed` and pre-GET `Active`, but does not re-read **`IPlayerQuestStateStore`** after GET. Adding a post-GET store assertion would strengthen coverage of the documented mutating read semantics (mirrors the `beforeGet` guard already in Arrange).~~ **Done.** Post-GET `TryGetProgress` asserts `Completed`, terminal step index, and `CompletedAt`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`MapRow`** throws **`InvalidOperationException`** on unknown **`QuestProgressStatus`** (would surface as 500). Acceptable defensive guard; optional direct **`BuildSnapshot`** unit test if parity with **`EncounterProgressApiTests`** corrupt-state tests is desired later.
|
||||
|
||||
- Nit: **`QuestProgressApiTests`** helper block (~200 lines) duplicates patterns from **`QuestObjectiveWiringTests`** / encounter quest helpers — acceptable for integration isolation; consider shared test fixture only if NEO-120 adds more HTTP quest tests.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Primary signal for this story
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~QuestProgressApiTests"
|
||||
|
||||
# Quest module regression
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~NeonSprawl.Server.Tests.Game.Quests"
|
||||
|
||||
# Full server suite (CI)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**Bruno:** run `bruno/neon-sprawl-server/quest-progress/Get quest progress default.bru` against a local server with fresh `dev-local-1`.
|
||||
|
||||
**Reviewer note:** All 7 `QuestProgressApiTests` and 85 quest-namespace tests passed locally during review.
|
||||
|
||||
**Manual:** None required beyond Bruno for this server-only story; Godot HUD verification belongs to NEO-122.
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# Code review — NEO-120 (E7M1-09)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-120-quest-accept-post` vs `origin/main` — commits `b22b68a` … `9fe6cb3`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-120 adds **`POST /game/players/{id}/quests/{questId}/accept`** — a thin HTTP wrapper over landed **`QuestStateOperations.TryAccept`**. The handler mirrors **`QuestProgressApi`** (trim id → position-store **404**), accepts optional v1 body with **`schemaVersion` 0 treated as omitted**, and returns a versioned **`QuestAcceptResponse`** (`accepted`, optional `reasonCode`, optional **`quest`** row) with **HTTP 200** on structured denies (inventory/craft precedent). Row projection is shared via extracted **`QuestProgressApi.MapQuestProgressRow(QuestStepState)`** overloads so GET and POST stay aligned. Eight AAA integration tests cover player 404 gates, happy accept (with GET confirmation), omitted body, invalid schema, prerequisite deny, duplicate accept snapshot, and unknown quest. Three Bruno accept spine requests complement the existing quest-progress folder. Plan reconciliation, **`server/README.md`**, and E7.M1 decomposition docs are updated. Server-only slice — Godot accept HUD correctly deferred to NEO-122. Risk is low; no blocking correctness or contract issues found.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-120-implementation-plan.md` | **Matches** — kickoff decisions adopted (`accepted` naming, shared mapper, optional body v1); acceptance checklist checked; reconciliation section accurate. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-09) | **Matches** — acceptance checkboxes checked; client counterpart NEO-122 noted. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-120 accept POST bullet + README/Bruno links. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-09 / NEO-120 landed note appended. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — NEO-120 note appended. |
|
||||
| `server/README.md` | **Matches** — route, optional body rules, response table, curl sample, wiring note. |
|
||||
| Full-stack epic decomposition | **Matches** — server-only story; plan explicitly defers Godot to NEO-122; does not claim prototype slice complete. |
|
||||
|
||||
**Register / tracking:** E7.M1 row correctly remains **Planned** until client capstone (NEO-123); no further register status change required for this merge.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**HTTP test for `already_completed` deny with snapshot** — The plan response table and README document **`already_completed`** returning **`accepted: false`** plus a **`quest`** row. Integration tests cover **`already_active`** and operations-layer **`TryAccept_ShouldDenyAlreadyCompleted`** exists in `QuestStateOperationsTests`, but no HTTP test exercises **`MapResponse`** for a completed row (status **`completed`**, optional **`completedAt`**). A single test completing gather intro then re-accepting would lock the wire contract for NEO-122.~~ **Done.** `PostQuestAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted` in `QuestAcceptApiTests.cs`.
|
||||
|
||||
2. ~~**Empty `{}` body test** — README documents `{}` as valid (default **`schemaVersion` 0**). Tests cover omitted body and explicit **`schemaVersion: 1`**; a one-liner POST with **`StringContent("{}")`** would close the loop on the documented third valid shape.~~ **Done.** `PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyIsEmptyObject` in `QuestAcceptApiTests.cs`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: **`MapQuestProgressRow(QuestStepState)`** throws **`InvalidOperationException`** on unknown **`QuestProgressStatus`** (would surface as 500). Inherited defensive guard from NEO-119; acceptable for prototype.
|
||||
|
||||
- ~~Nit: **`PostQuestAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted`** asserts on the first accept response inside **Arrange** (lines 159–162). Works fine; optional move of **`firstBody`** checks into **Assert** if strict AAA phase purity is desired.~~ **Done.** Arrange seeds active row without assertions; assert uses step index **0**.
|
||||
|
||||
- Nit: Bruno **`Accept duplicate.bru`** pre-request re-accepts gather intro (idempotent when seq 3 ran). Correct for standalone runs; collection order matters only when hitting a persistent dev server without reset.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Primary signal for this story
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~QuestAcceptApiTests"
|
||||
|
||||
# Quest module regression
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~NeonSprawl.Server.Tests.Game.Quests"
|
||||
|
||||
# Full server suite (CI)
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
```
|
||||
|
||||
**Bruno:** run the three accept requests in `bruno/neon-sprawl-server/quest-progress/` (seq 3–5) against a local server with fresh `dev-local-1`.
|
||||
|
||||
**Reviewer note:** All 8 `QuestAcceptApiTests` and 93 quest-namespace tests passed locally during review. **Follow-up:** suggestions 1–2 and nit on `already_active` AAA addressed — 10 `QuestAcceptApiTests` pass.
|
||||
|
||||
**Manual:** None required beyond Bruno for this server-only story; Godot accept binding verification belongs to NEO-122.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Code review — NEO-121 (E7M1-10)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-121-quest-telemetry-hooks` vs `origin/main` — commits `72b98a6` … `705a7cd`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-121 adds **comment-only** E9.M1 telemetry hook sites to **`QuestStateOperations`** for Epic 7 Slice 1 events **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, and **`quest_accept_denied`**. Hooks are placed at mutation commit sites (after successful store writes) and accept-deny paths funnel through a new **`DenyAccept`** helper (mirroring NEO-56 **`inventory_transfer_denied`**). **`DenyFromProgressSnapshotAccept`** ensures activate-failure rereads also hit the deny hook. No runtime behavior, wire contract, or API surface changes — only comments plus accept-path deny routing that still delegates to **`Deny`**. Documentation is thorough: implementation plan reconciliation, **`server/README.md`** event table, E7.M1 module snapshot, alignment register, backlog checkboxes, and **`docs/manual-qa/NEO-121.md`**. Server-only infrastructure slice; Godot HUD correctly deferred to NEO-122. Risk is negligible.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-121-implementation-plan.md` | **Matches** — kickoff decisions adopted (engine-only anchors, deny helper, idempotent complete exclusion); acceptance checklist checked; reconciliation accurate. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-10) | **Matches** — acceptance checkboxes checked; landed note + README link. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-121 implementation snapshot + Risks and telemetry bullet. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-10 / NEO-121 landed note appended. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7M1-10 note appended; row stays **Planned** until NEO-123 (per plan). |
|
||||
| `server/README.md` | **Matches** — Quest telemetry hooks subsection with event table and delegation note. |
|
||||
| `docs/manual-qa/NEO-121.md` | **Matches** — comment-only verification checklist; optional Bruno/regression steps. |
|
||||
| Full-stack epic decomposition | **N/A** — infrastructure-only; plan explicitly documents no client counterpart. |
|
||||
|
||||
**Register / tracking:** E7.M1 correctly remains **Planned** until client capstone NEO-123; E7M1-10 completion noted in register note only — no further status change required.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
(none)
|
||||
|
||||
## Nits
|
||||
|
||||
1. ~~**Nit:** Plan technical approach lists optional **`schemaVersion`** in **`quest_start`** planned payload; the inline comment omits it. Harmless for comment-only work; consider adding for parity when touching the file again.~~ **Done.** Added optional **`schemaVersion`** to **`quest_start`** inline comment.
|
||||
|
||||
2. ~~**Nit:** **`DenyFromProgressSnapshotAccept`** duplicates **`DenyFromProgressSnapshot`** except for **`DenyAccept`** vs **`Deny`** — intentional funneling, but E9.M1 wiring may want **`playerId`** / **`questId`** threaded into **`DenyAccept`** (comment already documents those fields; implementer will need caller context).~~ **Done.** Merged into **`DenyFromProgressSnapshot`** with optional accept-deny context; **`DenyAccept`** now takes **`playerId`** and **`questId`** at all call sites.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Full regression (691 tests passed at review time)
|
||||
dotnet test NeonSprawl.sln
|
||||
|
||||
# Quest-focused subset
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Quest"
|
||||
```
|
||||
|
||||
Manual (per `docs/manual-qa/NEO-121.md`):
|
||||
|
||||
- Walk **`QuestStateOperations.cs`** hook comment blocks and confirm no duplicate hooks in **`QuestAcceptApi`**, **`QuestProgressApi`**, or **`QuestObjectiveWiring`**.
|
||||
- Optional Bruno sanity in `bruno/neon-sprawl-server/quest-progress/` — accept happy path + duplicate deny unchanged.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# Code review follow-up — NEO-122 (E7M1-11)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-122-client-quest-progress-accept-hud` vs `origin/main` — commits `b36fe6d` … `632a747` (delta after [initial review](2026-06-07-NEO-122.md))
|
||||
**Base:** `origin/main`
|
||||
**Prior review:** `docs/reviews/2026-06-07-NEO-122.md` — prior suggestions **1** / **3** and nits **1–2** addressed in `b36fe6d`; suggestion **2** (extract quest HUD) still deferred.
|
||||
**Follow-up:** suggestions **1–2** below are **done** (strikethrough + **Done.**); suggestion **3** is manual regression spot-check for author/reviewer.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
Seven commits landed after the first review: code-review follow-up (quest-definitions error HUD + accept-failure GdUnit), then a **prototype economy/debug HUD layout refactor** so NEO-122 manual QA step 7 (craft **`refine_scrap_standard`** with recipes visible) is reliable. Changes include **`HudRootScroll`** wrapping debug labels, **`EconomyHudSection`** pinned to the viewport bottom with **`BodyScroll`**, **`craft_recipe_panel.gd`** flattened (no nested scroll; recipes-first in economy body), and layout clamping in **`prototype_economy_hud_section.gd`**. New/extended GdUnit: **`craft_recipe_panel_test.gd`**, viewport layout tests in **`prototype_economy_hud_section_test.gd`**. Quest client behavior from the first review remains sound; risk is mainly **plan/doc drift** and **cross-story HUD regression** (NEO-74/75/110), not quest contract correctness.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-122-implementation-plan.md` | ~~**Partially matches**~~ **Matches** — NEO-122 follow-up reconciliation added (economy HUD layout refactor). **Done.** |
|
||||
| `docs/manual-qa/NEO-122.md` | ~~**Partially matches**~~ **Matches** — duplicate step 9 fixed; acceptance includes craft visibility. **Done.** |
|
||||
| `client/README.md` | ~~**Partially matches**~~ **Matches** — test-scope includes **`craft_recipe_panel_test.gd`**. **Done.** |
|
||||
| `docs/manual-qa/NEO-74.md` / `NEO-75.md` / `NEO-110.md` | **Updated** — HudRootScroll / economy-pin regression notes added. **Done.** |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — unchanged since first review. |
|
||||
| Full-stack epic decomposition | **Matches** — still client slice only; NEO-123 capstone deferred. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Reconcile implementation plan** — Append a short **NEO-122 follow-up** bullet to `docs/plans/NEO-122-implementation-plan.md` **Implementation reconciliation**: economy HUD pinned layout, `HudRootScroll`, `craft_recipe_panel` VBox + `RecipeRows`, recipes-first in `BodyScroll/Body`, and manual QA step 7 rationale. Keeps [planning-implementation-docs](../../.cursor/rules/planning-implementation-docs.md) honest about scope expansion.~~ **Done.** Follow-up subsection in plan reconciliation; tests table extended.
|
||||
|
||||
2. ~~**Fix manual QA numbering** — In `docs/manual-qa/NEO-122.md`, renumber the second **9.** (server-down optional) to **10.** and the definitions-error optional to **11.**; update **Acceptance** if it should include the craft-visibility step (currently “Steps 1–8” while required steps now include **9**).~~ **Done.** Steps 1–12; acceptance covers steps 1–9 + craft visibility.
|
||||
|
||||
3. **Regression spot-check** — Run abbreviated passes of [NEO-74](../manual-qa/NEO-74.md), [NEO-75](../manual-qa/NEO-75.md), and [NEO-110](../manual-qa/NEO-110.md) after merge: collapsed economy toggle, gather/craft feedback still visible in **`HudRootScroll`**, encounter labels still scroll when debug stack is tall.
|
||||
|
||||
## Nits
|
||||
|
||||
1. Nit: `main.tscn` **`EconomyHudSection`** still has editor offsets (`offset_top = 400`) overwritten at runtime by **`_apply_viewport_layout`** — possible one-frame layout flash on boot; acceptable for prototype.
|
||||
|
||||
2. Nit: **`prototype_economy_hud_section.gd`** locates **`HudRootScroll`** by sibling name string — works with current scene tree and is covered by GdUnit; brittle if `UICanvas` children reorder.
|
||||
|
||||
3. Nit: Economy **`BodyScroll/Body`** child order is now **Recipes → Inventory → Skills → Gig XP** (was inventory-first). NEO-75 steps do not mandate order, but players accustomed to inventory-at-top may need a scroll on first open.
|
||||
|
||||
## Bugbot follow-up (PR #161)
|
||||
|
||||
1. ~~**Shift+Q ignores progress sync failure** — After GET fails, `_request_eligible_accept` blocked accept when `_progress_error` is set; GdUnit **`test_shift_q_blocks_when_progress_sync_failed`**. **Done.**
|
||||
|
||||
2. ~~**Hud scroll minimum causes panel overlap** — Removed 80px floor in `_apply_hud_root_scroll_bounds`; clamp to `maxf(0.0, available_height)`; layout test asserts scroll height ≤ panel top − gap. **Done.**
|
||||
|
||||
3. ~~**GdUnit CI exit 101 (orphans)** — Rewrote **`quest_hud_controller_test.gd`** with `MockHttpTransport` / `auto_free()` on all nodes; full suite **247 tests, 0 orphans, exit 0**. **Done.**
|
||||
|
||||
4. ~~**Quest HUD stale after accept** — Merge accept-response `quest` row into snapshot immediately; re-apply patch when stale GET returns `not_started`; queue pending sync when GET busy. **Done.**
|
||||
|
||||
5. ~~**Accept hidden by progress error** — Clear `_progress_error` on successful accept merge; render snapshot rows with `sync error — …` banner instead of error-only early return. **Done.**
|
||||
|
||||
6. ~~**Sync fail drops accept merge** — `_on_sync_failed` no longer clears `_accept_quest_patch` or merged snapshot; rebuilds from patch when snapshot empty. **Done.**
|
||||
|
||||
7. ~~**Shift+Q hides definitions failure** — Block eligible accept when `_defs_error` is set and catalog is empty. **Done.**
|
||||
|
||||
8. ~~**Definitions ready skips HUD repaint** — `_on_definitions_ready` repaints when snapshot exists even if `_progress_error` is set so display names replace raw ids. **Done.**
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd client
|
||||
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd \
|
||||
--ignoreHeadlessMode \
|
||||
-a res://test
|
||||
```
|
||||
|
||||
Full suite: **255** cases, **0** orphans (GdUnit exit **0**).
|
||||
|
||||
```bash
|
||||
# Focused NEO-122 / layout subset
|
||||
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd \
|
||||
--ignoreHeadlessMode \
|
||||
-a test/quest_progress_client_test.gd \
|
||||
test/quest_definitions_client_test.gd \
|
||||
test/quest_hud_controller_test.gd \
|
||||
test/craft_recipe_panel_test.gd \
|
||||
test/prototype_economy_hud_section_test.gd
|
||||
```
|
||||
|
||||
Manual (per updated `docs/manual-qa/NEO-122.md`):
|
||||
|
||||
1. **F5** with fresh server — quest labels visible in **`HudRootScroll`**; economy panel pinned bottom-left, not off-screen.
|
||||
2. Steps 1–9 (craft step 7–8: **Refine Scrap (Standard)** first row, scroll at top).
|
||||
3. Optional error paths (steps 10–11).
|
||||
4. Toggle **Economy HUD** off/on — debug labels in **`HudRootScroll`** remain; recipe repopulate + scroll reset still works.
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# Code review — NEO-122 (E7M1-11)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-122-client-quest-progress-accept-hud` vs `origin/main` — commits `150cb73` … `e37671d`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — suggestions **1** and **3** and nits **1–2** below are **done** (strikethrough + **Done.**); suggestion **2** deferred to NEO-123.
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-122 delivers the Godot client slice for Epic 7 quest visibility: **`quest_progress_client.gd`** (GET progress + POST accept with separate sync/accept HTTP and busy guards), **`quest_definitions_client.gd`** (cached display names + catalog-order iteration), HUD labels **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**, and **`main.gd`** wiring for boot hydrate, **Q** / **Shift+Q** accept, and event-driven GET refresh after gather, craft success, **`targetDefeated`**, and accept. Implementation mirrors **`encounter_progress_client.gd`** / **`recipe_definitions_client.gd`** precedents and respects server authority (client displays GET snapshots only; Shift+Q prerequisite check is UX-only). Twelve GdUnit cases cover parse, sync, 404, accept happy/deny, and definitions display names with full AAA layout. Documentation is thorough: implementation plan reconciliation, backlog checkboxes, E7.M1 module snapshot, alignment register, **`client/README.md`**, and **`docs/manual-qa/NEO-122.md`**. Risk is low; human manual QA remains the main pre-merge gate.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-122-implementation-plan.md` | **Matches** — kickoff decisions adopted (defs client, Q/Shift+Q semantics, four-quest HUD); reconciliation accurate; manual QA item correctly left open. |
|
||||
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-11) | **Matches** — acceptance checkboxes checked; landed note + README/manual QA links. |
|
||||
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — NEO-122 client HUD bullet + links. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7M1-11 / NEO-122 landed note appended. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 row stays **Planned** until NEO-123 capstone (correct). |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — client displays server GET snapshot; accept is intent POST; no local objective math. |
|
||||
| `client/README.md` | **Matches** — quest HUD subsection: routes, keys, refresh triggers, test file list. |
|
||||
| `docs/manual-qa/NEO-122.md` | **Matches** — Godot-only checklist; Shift+Q combat-intro caveat documented. |
|
||||
| Full-stack epic decomposition | **Matches** — paired client issue NEO-122; does not claim E7 Slice 1 capstone complete (NEO-123). |
|
||||
|
||||
**Register / tracking:** E7.M1 correctly remains **Planned** until client capstone NEO-123; no further register status change required for this merge.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Surface `definitions_sync_failed` on HUD** — `_quest_defs_error` is set in `_on_quest_definitions_sync_failed` but never read in `_render_quest_progress_label`. Recipe defs follow the visible pattern (`Recipes: error — {reason}` on **`CraftFeedbackLabel`**). Consider painting e.g. `Quests:\nerror — definitions {reason}` (or a suffix on the header) so a defs-only failure is visible; today the HUD silently falls back to raw quest ids from the progress snapshot.~~ **Done.** `_render_quest_progress_label` paints `definitions error — {reason}` when defs sync fails (during loading or alongside progress rows).
|
||||
|
||||
2. **Extract quest HUD wiring from `main.gd`** — ~250 lines of quest setup/render/accept logic were added to an already large composer. Per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md), a child **`quest_hud_controller.gd`** (signals in, label text out) would match NEO-110-era precedent deferral but reduce merge conflict surface before NEO-123 capstone work. Non-blocking for this story. **Deferred** to NEO-123 capstone or follow-up refactor.
|
||||
|
||||
3. ~~**GdUnit: `quest_accept_failed` + accept-busy guard** — Client tests cover sync 404 and structured deny (`accepted: false`), but not transport/HTTP failure on accept (`quest_accept_failed`) or `request_accept` returning `false` when `_accept_busy`. Small additions would lock the error path NEO-122 manual QA step 9 exercises.~~ **Done.** `test_http_404_on_accept_emits_quest_accept_failed` and `test_request_accept_returns_false_when_accept_busy` in `quest_progress_client_test.gd`.
|
||||
|
||||
## Nits
|
||||
|
||||
1. ~~Nit: `test_request_accept_emits_deny_with_reason_code` omits `await assert_signal(...)` used elsewhere; safe with the synchronous mock transport today, but inconsistent with `test_request_sync_emits_quest_progress_received`.~~ **Deferred.** Synchronous mock transport emits before `await assert_signal` can observe; deny test keeps sync capture (same pattern as `craft_client_test.gd`).
|
||||
|
||||
2. ~~Nit: `_render_quest_progress_labels()` is a one-line wrapper around `_render_quest_progress_label()` — harmless; could inline unless more quest labels land in NEO-123.~~ **Done.** Wrapper removed; callers invoke `_render_quest_progress_label()` directly.
|
||||
|
||||
3. Nit: Shift+Q may offer **`prototype_quest_combat_intro`** before refine when gather is still active (documented in manual QA Notes) — server deny is authoritative; acceptable prototype UX.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# GdUnit (requires Godot + gdUnit4 in PATH)
|
||||
cd client
|
||||
godot --headless --path . -s addons/gdUnit4/bin/GdUnitCmdTool.gd \
|
||||
--ignoreHeadlessMode -a test/quest_progress_client_test.gd test/quest_definitions_client_test.gd
|
||||
|
||||
# GDScript lint (if .venv-gd installed)
|
||||
gdlint client/scripts/quest_progress_client.gd client/scripts/quest_definitions_client.gd client/scripts/main.gd
|
||||
gdformat --check client/scripts/quest_progress_client.gd client/scripts/quest_definitions_client.gd
|
||||
```
|
||||
|
||||
Manual (per `docs/manual-qa/NEO-122.md`):
|
||||
|
||||
1. Fresh server + Godot **F5** — four quests **`not started`**; idle accept hint visible.
|
||||
2. **Q** accept gather → active step 1 + success feedback.
|
||||
3. Gather ×3 → counter updates; gather **`completed`**.
|
||||
4. **Shift+Q** before/after gather complete — deny vs refine accept.
|
||||
5. Craft **`refine_scrap_standard`** → refine **`completed`**.
|
||||
6. Duplicate **Q** on completed gather → **`denied — already_completed`** (or **`already_active`**).
|
||||
7. Optional: server down → **`Quests: error — …`** or **`Quest accept: failed — …`**.
|
||||
|
||||
Server regression (no server changes, sanity):
|
||||
|
||||
```bash
|
||||
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Quest"
|
||||
```
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Code review — NEO-124 (E7M2-01)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-124-e7m2-questrewardbundle-schema-quest-catalog-extension-ci` vs `origin/main` — commits `85ead01` … `c8e7449`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Alignment register E7.M2 References column repaired; negative CI spot-checks run (see Verification).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — blocking documentation corruption fixed; implementation matches plan.
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-124 locks the **`completionRewardBundle`** content contract for Epic 7 Slice 2: two new JSON Schemas (`quest-reward-bundle`, `quest-skill-xp-grant`), optional **`completionRewardBundle`** on `quest-def`, four frozen quest rows in `prototype_quests.json`, and three CI gates in `validate_content.py` (presence, freeze-table match with normalized sort, item/skill cross-refs + **`mission_reward`** allowlist). The work follows the NEO-112 content-only pattern, extends the quest `$ref` registry correctly, and documents the freeze in E7.M2, CT.M1, `content/README.md`, and the implementation plan. `python3 scripts/validate_content.py` passes locally. Risk is low for runtime (no server/client changes), but the corrupted alignment-table row should be fixed before merge.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-124-implementation-plan.md` | **Matches** — kickoff decisions (camelCase keys, omit `sourceKind`), acceptance criteria checked, reconciliation accurate. |
|
||||
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-01) | **Matches** — AC checkboxes complete; infrastructure-only client counterpart noted. |
|
||||
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — Slice 2 freeze table, camelCase keys, CI enforcement line. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — quest completion bundle PR-gate bullet added. |
|
||||
| `content/README.md` | **Matches** — E7 Slice 2 paragraph with cross-ref rules and links. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Conflicts** — E7.M2 row **References** column corrupted~~ **Matches** — References cell repaired (follow-up). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M2 note between table rows (same pattern as E7.M1); row stays **Planned** until NEO-132. |
|
||||
| `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 2 backlog link + Godot DoD line for NEO-132. |
|
||||
| Full-stack epic decomposition | **N/A** — E7M2-01 is infrastructure-only; no client counterpart required. |
|
||||
|
||||
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment row content is otherwise appropriate once corruption is fixed.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
1. ~~**Corrupted E7.M2 row in `documentation_and_implementation_alignment.md`** — line 62 ends the References column with a stray fragment:~~ **Done.** Replaced References cell with clean link list per review suggestion; table renders as one row.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Spot-check negative CI paths before merge** — Plan lists manual failure cases; run at least one each (typo `itemId`, wrong XP amount, skill missing `mission_reward`) to confirm gate messages are actionable.~~ **Done.** Ran three temporary mutations (restored after each): operator chain item typo → freeze gate exit 1; gather intro amount **24** → freeze gate exit 1; salvage without `mission_reward` → allowlist cross-ref exit 1. Note: item typo is caught by freeze gate before item cross-ref (gate order: presence → freeze → cross-ref).
|
||||
|
||||
2. **NEO-125 sync comment** — `PROTOTYPE_E7M2_COMPLETION_BUNDLES` already points at E7.M2 freeze; when server loader lands, mirror the constant in C# with an explicit cross-link comment (plan risk row already tracks this).
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `_prototype_e7m2_completion_bundle_cross_ref_gate` skips malformed grant dicts silently (same pattern as other E7M1 gates); schema validation already rejects them — consistent, no change needed.
|
||||
- Nit: `quest-reward-bundle.schema.json` `anyOf` allows `itemGrants: []` when `skillXpGrants` is non-empty; catalog correctly omits empty `itemGrants` per kickoff decision.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
Optional negative checks (expect non-zero exit + stderr message):
|
||||
|
||||
- Temporarily typo `survey_drone_kit` → confirm item cross-ref error.
|
||||
- Temporarily set gather intro `amount` to **24** → confirm freeze gate error.
|
||||
- Temporarily remove `mission_reward` from a skill's `allowedXpSourceKinds` → confirm allowlist error.
|
||||
|
||||
After fixing blocking issue **1**, re-read the E7.M2 alignment row in a Markdown preview to confirm the table renders as one row.
|
||||
|
||||
**Follow-up verification:** `python3 scripts/validate_content.py` passes on clean tree; negative spot-checks above executed in follow-up (see Suggestions **1**).
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Code review — NEO-125 (E7M2-02)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-125-e7m2-server-quest-catalog-reward-bundle-validation` vs `origin/main` — commits `47ddde6` … `8d026bd`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — implementation matches plan and CI parity; tests pass; minor test-naming/coverage notes only.
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-125 closes the server-side gap left by NEO-124: the quest catalog loader now registers bundle-related JSON Schemas (`reward-grant-row`, `quest-skill-xp-grant`, `quest-reward-bundle`), parses **`completionRewardBundle`** onto **`QuestDefRow`**, and runs **`PrototypeE7M2QuestCatalogRules`** (presence → freeze → cross-ref + **`mission_reward`** allowlist) at startup — mirroring **`scripts/validate_content.py`**. DI injects **`SkillDefinitionCatalog`** so skill allowlist checks use loaded defs. Five new loader tests extend the NEO-113 fixture pattern; host boot via **`WebApplicationFactory`** still resolves the catalog. Risk is low: infrastructure-only, fail-fast at startup, no HTTP or delivery changes.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-125-implementation-plan.md` | **Matches** — kickoff decisions, acceptance criteria checked, reconciliation accurate. |
|
||||
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-02) | **Matches** — AC checkboxes complete; infrastructure-only client counterpart noted. |
|
||||
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — server load paragraph added; freeze table unchanged. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes NEO-125 server load; row stays **Planned** until NEO-132 capstone. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M2 note references NEO-125; register row correctly **Planned**. |
|
||||
| `server/README.md` | **Matches** — quest catalog section documents E7M2 gates, bundle schemas, skill catalog dependency. |
|
||||
| Full-stack epic decomposition | **N/A** — E7M2-02 is infrastructure-only; no client counterpart required. |
|
||||
|
||||
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment updates are appropriate.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Rename misleading bundle cross-ref tests** — `Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle` and `Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle` mutate bundle ids so the **freeze** gate fails first (assertions correctly expect `"completionRewardBundle must match E7M2 freeze table"`). Consider renaming to `…WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable` / `…SkillIdDoesNotMatchFreezeTable`, or add a focused unit test on **`PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError`** with a matching-freeze bundle to assert the cross-ref message paths (`is not in the frozen prototype item catalog` / skill catalog).~~ **Done.** Renamed loader tests; added **`PrototypeE7M2QuestCatalogRulesTests`** with direct cross-ref coverage.
|
||||
|
||||
2. ~~**Manual boot smoke (plan §Tests)** — Plan lists booting the dev server and confirming the quest catalog Information log; worth a quick run before merge since NEO-124 content now depends on bundle schema registration at host startup.~~ **Done.** Dev server boot logged `Loaded quest catalog from …/content/quests: 4 quest(s) across 1 JSON catalog file(s).` with no startup exception.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: Bundle schema paths use sibling `content/schemas/` resolution only; quest def/step/objective schemas support **`Content:*`** overrides. Plan marked overrides as optional — fine for prototype; **`server/README.md`** now documents that bundle schemas have no separate override keys yet.
|
||||
- ~~Nit: In **`TryGetCompletionBundleCrossRefError`**, a skill id present in **`PrototypeSlice1SkillCatalogRules.ExpectedSkillIds`** but missing from **`skillDefsById`** returns the “not in the frozen prototype skill catalog” message; Python’s cross-ref gate would report a **`mission_reward`** allowlist failure instead. Unreachable in production DI (skills load before quests) but slightly divergent for isolated unit tests.~~ **Done.** Missing loaded skill defs now mirror Python — empty **`allowedXpSourceKinds`** → **`mission_reward`** allowlist error.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
dotnet test server/NeonSprawl.Server.Tests --filter QuestDefinitionCatalogLoaderTests
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
Optional before merge:
|
||||
|
||||
- Boot dev server; confirm Information log for quest catalog load (4 quests) with no startup exception.
|
||||
- If renaming tests (Suggestion **1**), re-run the filter above.
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# Code review — NEO-126 (E7M2-03)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-126-e7m2-reward-delivery-store` vs `origin/main` — commits `b20d9c1` … `2a8fe31`
|
||||
**Base:** `origin/main`
|
||||
**Follow-up:** Suggestions below are **done** (strikethrough + **Done.**).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — store contract, threading model, and tests mirror the NEO-107 encounter-event precedent; plan and backlog AC are satisfied; risk is low (infrastructure-only, no router or HTTP surface yet).
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-126 adds **`IRewardDeliveryStore`** and **`InMemoryRewardDeliveryStore`** under `Game/Rewards/` for idempotent quest-completion reward delivery records. **`RewardDeliveryEvent`** carries item and skill XP grant snapshots plus a stable **`{playerId}:quest_complete:{questId}`** idempotency key; **`RewardDeliveryIds`** handles normalization and composite store keys. DI registers an in-memory singleton via **`AddRewardDeliveryStore()`** in **`Program.cs`**. Five AAA unit tests cover idempotency, fail-closed empty ids, grant summary readability, and host DI resolution; Bruno adds a health smoke for **`Program.cs`** pre-commit. Docs (implementation plan, E7M2-03 backlog AC, alignment register, **`server/README.md`**) are updated. No player-visible behavior until NEO-127 wires the router.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-126-implementation-plan.md` | **Matches** — kickoff (in-memory only), acceptance criteria checked, reconciliation accurate. |
|
||||
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-03) | **Matches** — AC checkboxes complete; infrastructure-only client counterpart noted. |
|
||||
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-126 implementation anchor added (review follow-up). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-03 delivery store; row correctly stays **Planned** until NEO-132 capstone. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required for this slice. |
|
||||
| `server/README.md` | **Matches** — reward delivery store section documents interface, event fields, idempotency key, NEO-127/NEO-129 pointers. |
|
||||
| Full-stack epic decomposition | **N/A** — E7M2-03 is infrastructure-only; no client counterpart required. |
|
||||
|
||||
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E7.M2 module anchor** — Add a short **Implementation anchor (server)** bullet for NEO-126 in `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` (parity with the NEO-125 server-load and NEO-107 encounter-store paragraphs) so decomposition readers do not rely only on the alignment register and README.~~ **Done.** NEO-126 paragraph under **Implementation anchor (server)** in `E7_M2_RewardAndUnlockRouter.md`.
|
||||
|
||||
2. ~~**Case-normalization idempotency test** — `MakeStoreKey` lowercases ids, but no test proves a second `TryRecord` with different casing of the same player/quest is rejected. A one-liner extension of `TryRecord_ShouldBeIdempotent_AtStoreLayer` (e.g. replay with `PlayerId = "DEV-LOCAL-1"`) would lock that behavior for NEO-127 replays.~~ **Done.** Third replay with mixed-case ids in `InMemoryRewardDeliveryStoreTests.TryRecord_ShouldBeIdempotent_AtStoreLayer`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `TryRecord` does not validate that `deliveryEvent.IdempotencyKey` matches `RewardDeliveryIds.MakeIdempotencyKey(...)` — same as `InMemoryEncounterCompleteEventStore`; NEO-127 router should populate both consistently.
|
||||
- Nit: `TryClear` is untested; acceptable for dev-only API (encounter store also omits a `TryClear` test).
|
||||
- Nit: Stored `PlayerId` / `QuestId` on the event are not rewritten to normalized form at record time; lookup normalizes via `MakeStoreKey`. Router (NEO-127) should pass normalized ids per plan table.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
dotnet test server/NeonSprawl.Server.Tests --filter InMemoryRewardDeliveryStoreTests
|
||||
```
|
||||
|
||||
Optional before merge:
|
||||
|
||||
- Boot dev server; confirm `/health` 200 after `AddRewardDeliveryStore()` wiring (Bruno: `bruno/neon-sprawl-server/quest-progress/Health after reward delivery store load.bru`).
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Code review — NEO-127 (E7M2-04)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Scope:** Branch `NEO-127-e7m2-reward-router-operations` vs `origin/main` — commits `35b8904` … `65ca111`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits** — router apply, compensating rollback, idempotent replay, and store record flow mirror the NEO-105 encounter-completion precedent; plan AC and module contracts are satisfied; risk is low (server-internal only until NEO-128 wiring).
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-127 adds **`RewardRouterOperations.TryDeliverQuestCompletion`** under `Game/Rewards/` to apply quest **`completionRewardBundle`** rows: item grants via **`PlayerInventoryOperations`** (simulate-all pre-flight, sequential apply, compensating rollback) and skill XP via **`SkillProgressionGrantOperations.TryApplyGrant`** with fixed **`mission_reward`** source kind. **`RewardDeliveryResult`** and **`RewardDeliveryReasonCodes`** provide structured outcomes; **`IRewardDeliveryStore`** records **`RewardDeliveryEvent`** on success with idempotent replay via **`TryGet`** before apply and race handling when **`TryRecord`** returns `false`. Ten AAA integration tests cover happy paths, inventory deny, skill deny rollback, store replay, unknown-skill deny, null-bundle empty record, and invalid-id deny. Docs (`NEO-127` plan reconciliation, E7.M2 module anchor, alignment register, `server/README.md`) are updated. No player-visible behavior until NEO-128 wires quest completion.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Path | Result |
|
||||
|------|--------|
|
||||
| `docs/plans/NEO-127-implementation-plan.md` | **Matches** — kickoff decisions adopted; AC checked; reconciliation accurate. |
|
||||
| `docs/plans/E7M2-prototype-backlog.md` (E7M2-04) | **Matches** — AC checkboxes complete; in-scope skill XP path aligned with **`TryApplyGrant`**. |
|
||||
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-127 implementation anchor documents router apply path. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes E7M2-04 router apply; row correctly stays **Planned** until NEO-132 capstone. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — no register change required for this slice. |
|
||||
| `server/README.md` | **Matches** — reward router section documents entry point, reason codes, apply order, idempotency + store race behavior. |
|
||||
| Full-stack epic decomposition | **N/A** — E7M2-04 is server engine only; no client counterpart required. |
|
||||
|
||||
**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment update is appropriate.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Empty / null bundle test** — Kickoff decision and plan both require success with empty grant snapshots and **`TryRecord`** for null or both-empty bundles. Code handles this (skips inventory pre-flight, records empty arrays), but no test locks the behavior. A single AAA test with `bundle: null` or `new QuestRewardBundleRow([], [])` would close the gap.~~ **Done.** `TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull`.
|
||||
|
||||
2. ~~**Invalid id deny tests** — `invalid_player_id` / `invalid_quest_id` are implemented and documented in `server/README.md`, but untested. Two small deny-path tests (empty/whitespace ids) would match the fail-closed store precedent from NEO-126.~~ **Done.** `TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty` and `…InvalidQuestId…`.
|
||||
|
||||
3. ~~**Replay XP assertion** — In `TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly`, the second-call assertion reuses `salvageXp` captured before the replay `Deliver` call. Re-fetch **`GetXpTotals`** after the second call to prove no double XP grant on idempotent replay.~~ **Done.** Gather intro test re-fetches XP after replay.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: Pre-flight **`TrySimulateAddStack`** failure always denies with **`inventory_full`**, even when the underlying cause would be **`invalid_item`** or **`invalid_quantity`** at apply time. Same as **`EncounterCompletionOperations`** (NEO-105); acceptable for prototype catalog-gated content; apply-phase denies still return correct passthrough codes.
|
||||
|
||||
- Nit: When **`TryRecord`** returns `false` and a subsequent **`TryGet`** also misses, the router still returns success with the locally built **`deliveryEvent`** (line 149 fallback). Extremely unlikely with the in-memory store; mirrors “retain grants, treat as idempotent success” policy from the plan. Consider a debug log if this path ever surfaces in production persistence.
|
||||
|
||||
- Nit: **`CompensatingRemoveItems`** ignores **`TryRemoveStack`** outcomes (same as encounter compensating helper). Acceptable for prototype; multi-item bundles with partial rollback failure would be a future hardening item.
|
||||
|
||||
- Nit: Optional plan test for **`source_kind_not_allowed`** negative path not implemented; catalog startup gates make this low priority.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
dotnet test server/NeonSprawl.Server.Tests --filter RewardRouterOperationsTests
|
||||
```
|
||||
|
||||
All 14 tests pass (verified 2026-06-07; includes concurrent level-up winner perk preservation on **`TryRecord`** race loss).
|
||||
|
||||
Optional before merge:
|
||||
|
||||
- Boot dev server; confirm `/health` 200 — no new DI registration; behavior unchanged until NEO-128.
|
||||
|
|
@ -14,7 +14,8 @@ Validates:
|
|||
- npc behavior catalogs: content/npc-behaviors/*_npc_behaviors.json rows vs content/schemas/npc-behavior-def.schema.json (NEO-87)
|
||||
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
|
||||
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
|
||||
- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112)
|
||||
- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112);
|
||||
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -43,6 +44,8 @@ ENCOUNTER_DEF_SCHEMA = REPO_ROOT / "content/schemas/encounter-def.schema.json"
|
|||
QUEST_OBJECTIVE_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-objective-def.schema.json"
|
||||
QUEST_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json"
|
||||
QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json"
|
||||
QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json"
|
||||
QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
|
|
@ -170,6 +173,56 @@ PROTOTYPE_E7M1_QUEST_IDS = frozenset(
|
|||
PROTOTYPE_E7M1_CHAIN_QUEST_ID = "prototype_quest_operator_chain"
|
||||
PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID = "contract_handoff_token"
|
||||
|
||||
# Epic 7 Slice 2 prototype lock (NEO-124): completionRewardBundle on four frozen quests.
|
||||
# Keep in sync with E7.M2 freeze table and future NEO-125 server loader.
|
||||
PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND = "mission_reward"
|
||||
PROTOTYPE_E7M2_COMPLETION_BUNDLES: dict[str, dict] = {
|
||||
"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}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_completion_bundle(bundle: dict) -> dict:
|
||||
"""Normalize grant rows for stable freeze-table comparison."""
|
||||
item_grants = bundle.get("itemGrants")
|
||||
skill_xp_grants = bundle.get("skillXpGrants")
|
||||
return {
|
||||
"itemGrants": sorted(
|
||||
[
|
||||
{"itemId": grant["itemId"], "quantity": grant["quantity"]}
|
||||
for grant in (item_grants if isinstance(item_grants, list) else [])
|
||||
if isinstance(grant, dict)
|
||||
and isinstance(grant.get("itemId"), str)
|
||||
and isinstance(grant.get("quantity"), int)
|
||||
],
|
||||
key=lambda grant: (grant["itemId"], grant["quantity"]),
|
||||
),
|
||||
"skillXpGrants": sorted(
|
||||
[
|
||||
{"skillId": grant["skillId"], "amount": grant["amount"]}
|
||||
for grant in (skill_xp_grants if isinstance(skill_xp_grants, list) else [])
|
||||
if isinstance(grant, dict)
|
||||
and isinstance(grant.get("skillId"), str)
|
||||
and isinstance(grant.get("amount"), int)
|
||||
],
|
||||
key=lambda grant: (grant["skillId"], grant["amount"]),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
|
|
@ -842,15 +895,21 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str
|
|||
|
||||
|
||||
def _quest_def_validator() -> Draft202012Validator:
|
||||
"""Build a validator that resolves quest-def $ref to step and objective schemas."""
|
||||
"""Build a validator that resolves quest-def $ref to step, objective, and bundle schemas."""
|
||||
objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8"))
|
||||
grant_row_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
|
||||
registry = Registry().with_resources(
|
||||
[
|
||||
(def_schema["$id"], Resource.from_contents(def_schema)),
|
||||
(step_schema["$id"], Resource.from_contents(step_schema)),
|
||||
(objective_schema["$id"], Resource.from_contents(objective_schema)),
|
||||
(bundle_schema["$id"], Resource.from_contents(bundle_schema)),
|
||||
(skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)),
|
||||
(grant_row_schema["$id"], Resource.from_contents(grant_row_schema)),
|
||||
]
|
||||
)
|
||||
return Draft202012Validator(def_schema, registry=registry)
|
||||
|
|
@ -1071,6 +1130,82 @@ def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | Non
|
|||
return None
|
||||
|
||||
|
||||
def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if a frozen quest lacks completionRewardBundle, else None."""
|
||||
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS):
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {qid!r}"
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
return f"error: quest {qid!r} must include completionRewardBundle object"
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m2_completion_bundle_freeze_gate(id_to_row: dict[str, dict]) -> str | None:
|
||||
"""Return a human-readable error if bundle contents diverge from E7M2 freeze table, else None."""
|
||||
for qid, expected in PROTOTYPE_E7M2_COMPLETION_BUNDLES.items():
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
return f"error: missing quest {qid!r}"
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
return f"error: quest {qid!r} must include completionRewardBundle object"
|
||||
actual = _normalize_completion_bundle(bundle)
|
||||
expected_normalized = _normalize_completion_bundle(expected)
|
||||
if actual != expected_normalized:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle must match E7M2 freeze table "
|
||||
f"(expected {expected_normalized!r}, got {actual!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_e7m2_completion_bundle_cross_ref_gate(
|
||||
id_to_row: dict[str, dict],
|
||||
skill_id_to_allowed_kinds: dict[str, list[str]],
|
||||
) -> str | None:
|
||||
"""Return a human-readable error if bundle refs fail cross-checks, else None."""
|
||||
for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS):
|
||||
row = id_to_row.get(qid)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
bundle = row.get("completionRewardBundle")
|
||||
if not isinstance(bundle, dict):
|
||||
continue
|
||||
item_grants = bundle.get("itemGrants")
|
||||
if isinstance(item_grants, list):
|
||||
for gi, grant in enumerate(item_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
item_id = grant.get("itemId")
|
||||
if isinstance(item_id, str) and item_id not in PROTOTYPE_SLICE1_ITEM_IDS:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle.itemGrants[{gi}]: itemId "
|
||||
f"{item_id!r} is not in the frozen prototype item catalog"
|
||||
)
|
||||
skill_xp_grants = bundle.get("skillXpGrants")
|
||||
if isinstance(skill_xp_grants, list):
|
||||
for gi, grant in enumerate(skill_xp_grants):
|
||||
if not isinstance(grant, dict):
|
||||
continue
|
||||
skill_id = grant.get("skillId")
|
||||
if isinstance(skill_id, str) and skill_id not in PROTOTYPE_SLICE1_SKILL_IDS:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle.skillXpGrants[{gi}]: skillId "
|
||||
f"{skill_id!r} is not in the frozen prototype skill catalog"
|
||||
)
|
||||
allowed_kinds = skill_id_to_allowed_kinds.get(skill_id, [])
|
||||
if PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND not in allowed_kinds:
|
||||
return (
|
||||
f"error: quest {qid!r} completionRewardBundle.skillXpGrants[{gi}]: skillId "
|
||||
f"{skill_id!r} must allow sourceKind "
|
||||
f"{PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND!r} in allowedXpSourceKinds "
|
||||
f"(got {allowed_kinds!r})"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||
if len(track_skill_ids) != 1:
|
||||
|
|
@ -1415,6 +1550,12 @@ def main() -> int:
|
|||
if not QUEST_DEF_SCHEMA.is_file():
|
||||
print(f"error: missing schema {QUEST_DEF_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not QUEST_REWARD_BUNDLE_SCHEMA.is_file():
|
||||
print(f"error: missing schema {QUEST_REWARD_BUNDLE_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file():
|
||||
print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
|
||||
skill_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -1540,6 +1681,7 @@ def main() -> int:
|
|||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
skill_id_to_allowed_kinds: dict[str, list[str]] = {}
|
||||
errors = 0
|
||||
|
||||
for path in skill_files:
|
||||
|
|
@ -1579,6 +1721,11 @@ def main() -> int:
|
|||
cat = row.get("category")
|
||||
if isinstance(cat, str):
|
||||
id_to_category[sid] = cat
|
||||
allowed_kinds = row.get("allowedXpSourceKinds")
|
||||
if isinstance(allowed_kinds, list):
|
||||
skill_id_to_allowed_kinds[sid] = [
|
||||
kind for kind in allowed_kinds if isinstance(kind, str)
|
||||
]
|
||||
|
||||
for path in curve_files:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
|
@ -1831,6 +1978,24 @@ def main() -> int:
|
|||
print(e7m1_chain_terminal_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m2_bundle_presence_err = _prototype_e7m2_completion_bundle_presence_gate(quest_id_to_row)
|
||||
if e7m2_bundle_presence_err:
|
||||
print(e7m2_bundle_presence_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m2_bundle_freeze_err = _prototype_e7m2_completion_bundle_freeze_gate(quest_id_to_row)
|
||||
if e7m2_bundle_freeze_err:
|
||||
print(e7m2_bundle_freeze_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
e7m2_bundle_cross_ref_err = _prototype_e7m2_completion_bundle_cross_ref_gate(
|
||||
quest_id_to_row,
|
||||
skill_id_to_allowed_kinds,
|
||||
)
|
||||
if e7m2_bundle_cross_ref_err:
|
||||
print(e7m2_bundle_cross_ref_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(
|
||||
"content OK: "
|
||||
f"{len(skill_files)} skill catalog file(s), "
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
|
@ -31,7 +32,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -72,7 +76,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -104,7 +111,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
|
|
@ -138,7 +148,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -169,7 +182,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
|
|
@ -201,7 +217,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -230,7 +249,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -255,7 +277,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -283,7 +308,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
|
@ -313,7 +341,10 @@ public sealed class CraftOperationsTests
|
|||
deps.SkillRegistry,
|
||||
failingXpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine);
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
|
|
@ -359,7 +390,9 @@ public sealed class CraftOperationsTests
|
|||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>());
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -394,7 +427,9 @@ public sealed class CraftOperationsTests
|
|||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore XpStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine);
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore);
|
||||
|
||||
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -34,6 +35,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -63,6 +66,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -96,6 +101,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -127,6 +134,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -151,6 +160,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
|
||||
|
||||
|
|
@ -167,6 +178,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -197,6 +210,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -236,6 +251,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -272,6 +289,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -292,6 +311,8 @@ public sealed class EncounterCombatWiringTests
|
|||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +361,9 @@ public sealed class EncounterCombatWiringTests
|
|||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>());
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -364,5 +387,7 @@ public sealed class EncounterCombatWiringTests
|
|||
IEncounterCompletionStore CompletionStore,
|
||||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore);
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore QuestProgressStore);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -34,6 +35,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
timeProvider);
|
||||
|
||||
// Assert
|
||||
|
|
@ -75,6 +78,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
timeProvider);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
|
||||
|
||||
|
|
@ -89,6 +94,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
timeProvider);
|
||||
|
||||
// Assert
|
||||
|
|
@ -145,6 +152,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -177,6 +186,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -211,6 +222,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -243,6 +256,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
inventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -273,6 +288,8 @@ public sealed class EncounterCompletionOperationsTests
|
|||
deps.EventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.QuestProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
|
|
@ -333,7 +350,9 @@ public sealed class EncounterCompletionOperationsTests
|
|||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>());
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -371,7 +390,9 @@ public sealed class EncounterCompletionOperationsTests
|
|||
IEncounterCompletionStore CompletionStore,
|
||||
IEncounterCompleteEventStore EventStore,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore);
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore QuestProgressStore);
|
||||
|
||||
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
|
@ -32,7 +33,10 @@ public sealed class GatherOperationsTests
|
|||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
var salvageXp = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
|
@ -83,7 +87,10 @@ public sealed class GatherOperationsTests
|
|||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
|
@ -116,7 +123,10 @@ public sealed class GatherOperationsTests
|
|||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
Assert.True(gather.Success);
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +144,10 @@ public sealed class GatherOperationsTests
|
|||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
var salvageXpAfter = deps.XpStore.GetXpTotals(PlayerId).GetValueOrDefault("salvage");
|
||||
|
|
@ -166,7 +179,10 @@ public sealed class GatherOperationsTests
|
|||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
var rowExists = deps.InstanceStore.TryGetRemainingGathers(unknownId, out _);
|
||||
|
||||
|
|
@ -196,7 +212,10 @@ public sealed class GatherOperationsTests
|
|||
failingXpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
deps.InstanceStore.TryGetRemainingGathers(AlphaId, out var nodeSnapshot);
|
||||
|
|
@ -227,7 +246,10 @@ public sealed class GatherOperationsTests
|
|||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore);
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
TimeProvider.System);
|
||||
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
|
||||
|
|
@ -249,7 +271,9 @@ public sealed class GatherOperationsTests
|
|||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>());
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>(),
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
}
|
||||
|
||||
private static int CountItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
|
|
@ -285,7 +309,9 @@ public sealed class GatherOperationsTests
|
|||
IPlayerSkillProgressionStore XpStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IResourceNodeInstanceStore InstanceStore);
|
||||
IResourceNodeInstanceStore InstanceStore,
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore);
|
||||
|
||||
private sealed class ProgressionStoreAlwaysMissing : IPlayerSkillProgressionStore
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ using NeonSprawl.Server.Game.Items;
|
|||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Npgsql;
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
d.ServiceType == typeof(IPlayerInventoryStore) ||
|
||||
d.ServiceType == typeof(IResourceNodeInstanceStore) ||
|
||||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||
d.ServiceType == typeof(IPlayerQuestStateStore) ||
|
||||
d.ServiceType == typeof(TimeProvider) ||
|
||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
||||
|
|
@ -89,6 +91,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
|||
services.AddSingleton<IPlayerInventoryStore, InMemoryPlayerInventoryStore>();
|
||||
services.AddSingleton<IResourceNodeInstanceStore, InMemoryResourceNodeInstanceStore>();
|
||||
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||
services.AddSingleton<IPlayerQuestStateStore, InMemoryPlayerQuestStateStore>();
|
||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public class PrototypeE7M2QuestCatalogRulesTests
|
||||
{
|
||||
private static readonly IReadOnlyList<QuestStepDefRow> EmptySteps =
|
||||
[new QuestStepDefRow("step", "Step", [])];
|
||||
|
||||
private static QuestDefRow QuestWithBundle(string questId, QuestRewardBundleRow bundle) =>
|
||||
new(questId, "Test", [], EmptySteps, bundle);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, SkillDefRow> ValidSkillDefs =
|
||||
new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
["salvage"] = new("salvage", "gather", "Salvage", ["activity", "mission_reward"]),
|
||||
["refine"] = new("refine", "process", "Refine", ["activity", "mission_reward", "trainer"]),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void TryGetCompletionBundleCrossRefError_ShouldReturnError_WhenItemIdNotInFrozenCatalog()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = QuestWithBundle(
|
||||
PrototypeE7M1QuestCatalogRules.ChainQuestId,
|
||||
new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("unknown_item_id", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)])),
|
||||
};
|
||||
// Act
|
||||
var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, ValidSkillDefs);
|
||||
// Assert
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("completionRewardBundle.itemGrants[0]: itemId 'unknown_item_id'", error, StringComparison.Ordinal);
|
||||
Assert.Contains("is not in the frozen prototype item catalog", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetCompletionBundleCrossRefError_ShouldReturnError_WhenSkillIdNotInFrozenCatalog()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = QuestWithBundle(
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||
new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)])),
|
||||
};
|
||||
// Act
|
||||
var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, ValidSkillDefs);
|
||||
// Assert
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("completionRewardBundle.skillXpGrants[0]: skillId 'unknown_skill_id'", error, StringComparison.Ordinal);
|
||||
Assert.Contains("is not in the frozen prototype skill catalog", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetCompletionBundleCrossRefError_ShouldReturnError_WhenSkillMissingFromLoadedDefs()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = QuestWithBundle(
|
||||
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
|
||||
new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)])),
|
||||
};
|
||||
// Act
|
||||
var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(
|
||||
rows,
|
||||
new Dictionary<string, SkillDefRow>(StringComparer.Ordinal));
|
||||
// Assert
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds (got [])", error, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetCompletionBundleCrossRefError_ShouldReturnNull_WhenBundlesMatchFreezeAndAllowlists()
|
||||
{
|
||||
// Arrange
|
||||
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal);
|
||||
foreach (var (qid, bundle) in PrototypeE7M2QuestCatalogRules.ExpectedCompletionBundles)
|
||||
{
|
||||
rows[qid] = QuestWithBundle(qid, bundle);
|
||||
}
|
||||
|
||||
// Act
|
||||
var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, ValidSkillDefs);
|
||||
// Assert
|
||||
Assert.Null(error);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public sealed class QuestAcceptApiTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/missing-player/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/%20/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenGatherIntroNotStarted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(QuestAcceptResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.True(body.Accepted);
|
||||
Assert.Null(body.ReasonCode);
|
||||
Assert.NotNull(body.Quest);
|
||||
Assert.Equal(GatherQuestId, body.Quest!.QuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, body.Quest.Status);
|
||||
Assert.Equal(0, body.Quest.CurrentStepIndex);
|
||||
Assert.Empty(body.Quest.ObjectiveCounters);
|
||||
Assert.Null(body.Quest.CompletedAt);
|
||||
|
||||
var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
var progress = await getResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(progress);
|
||||
var row = Assert.Single(progress!.Quests, static r => r.QuestId == GatherQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, row.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyOmitted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
content: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.Equal(GatherQuestId, body.Quest!.QuestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnAcceptedTrue_WhenRequestBodyIsEmptyObject()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
using var content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Accepted);
|
||||
Assert.Equal(GatherQuestId, body.Quest!.QuestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldReturnBadRequest_WhenSchemaVersionInvalid()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(new { schemaVersion = 99 }),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyPrerequisiteIncomplete_WhenRefineIntroBeforeGatherComplete()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{RefineQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.PrerequisiteIncomplete, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
|
||||
var getResponse = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
var progress = await getResponse.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(progress);
|
||||
var row = Assert.Single(progress!.Quests, static r => r.QuestId == RefineQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyAlreadyActive_WhenQuestAlreadyAccepted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
_ = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.AlreadyActive, body.ReasonCode);
|
||||
Assert.NotNull(body.Quest);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, body.Quest!.Status);
|
||||
Assert.Equal(0, body.Quest.CurrentStepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyAlreadyCompleted_WhenQuestAlreadyCompleted()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var questRegistry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
|
||||
var progressStore = factory.Services.GetRequiredService<IPlayerQuestStateStore>();
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
factory.Services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
factory.Services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
timeProvider).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
timeProvider).Success);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/{GatherQuestId}/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.AlreadyCompleted, body.ReasonCode);
|
||||
Assert.NotNull(body.Quest);
|
||||
Assert.Equal(QuestProgressApi.StatusCompleted, body.Quest!.Status);
|
||||
Assert.Equal(0, body.Quest.CurrentStepIndex);
|
||||
Assert.NotNull(body.Quest.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostQuestAccept_ShouldDenyUnknownQuest_WhenQuestIdNotInCatalog()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsync(
|
||||
$"/game/players/{PlayerId}/quests/prototype_quest_missing/accept",
|
||||
JsonContent.Create(new QuestAcceptRequest { SchemaVersion = QuestAcceptRequest.CurrentSchemaVersion }));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestAcceptResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.Accepted);
|
||||
Assert.Equal(QuestStateReasonCodes.UnknownQuest, body.ReasonCode);
|
||||
Assert.Null(body.Quest);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,4 +26,13 @@ internal static class QuestCatalogTestPaths
|
|||
DiscoverRepoQuestsDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
|
||||
internal static string DiscoverRepoRewardGrantRowSchemaPath() =>
|
||||
QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoQuestsDirectory());
|
||||
|
||||
internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() =>
|
||||
QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoQuestsDirectory());
|
||||
|
||||
internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
|
||||
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ using NeonSprawl.Server.Game.Crafting;
|
|||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using NeonSprawl.Server.Tests.Game.Skills;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
|
@ -22,6 +24,17 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
|
||||
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, SkillDefRow> RepoSkillDefs = LoadRepoSkillDefs();
|
||||
|
||||
private static IReadOnlyDictionary<string, SkillDefRow> LoadRepoSkillDefs()
|
||||
{
|
||||
var catalog = SkillDefinitionCatalogLoader.Load(
|
||||
SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(),
|
||||
SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(),
|
||||
NullLogger.Instance);
|
||||
return catalog.ById;
|
||||
}
|
||||
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
|
|
@ -31,6 +44,9 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
"id": "prototype_quest_gather_intro",
|
||||
"displayName": "Intro: Salvage Run",
|
||||
"prerequisiteQuestIds": [],
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "gather_intro_step_salvage",
|
||||
|
|
@ -50,6 +66,9 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
"id": "prototype_quest_refine_intro",
|
||||
"displayName": "Intro: Refine Stock",
|
||||
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "refine", "amount": 25 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "refine_intro_step_craft",
|
||||
|
|
@ -69,6 +88,9 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
"id": "prototype_quest_combat_intro",
|
||||
"displayName": "Intro: Clear the Pocket",
|
||||
"prerequisiteQuestIds": [],
|
||||
"completionRewardBundle": {
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "combat_intro_step_encounter",
|
||||
|
|
@ -91,6 +113,10 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
"prototype_quest_refine_intro",
|
||||
"prototype_quest_combat_intro"
|
||||
],
|
||||
"completionRewardBundle": {
|
||||
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
|
||||
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "chain_step_gather",
|
||||
|
|
@ -159,6 +185,9 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
|
||||
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
|
||||
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||
}
|
||||
|
||||
|
|
@ -182,15 +211,20 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
string questsDir,
|
||||
string defSchemaPath,
|
||||
string stepSchemaPath,
|
||||
string objectiveSchemaPath) =>
|
||||
string objectiveSchemaPath,
|
||||
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null) =>
|
||||
QuestDefinitionCatalogLoader.Load(
|
||||
questsDir,
|
||||
defSchemaPath,
|
||||
stepSchemaPath,
|
||||
objectiveSchemaPath,
|
||||
QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir),
|
||||
QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir),
|
||||
QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir),
|
||||
KnownItemIds,
|
||||
KnownRecipeIds,
|
||||
KnownEncounterIds,
|
||||
skillDefsById ?? RepoSkillDefs,
|
||||
NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
|
|
@ -229,6 +263,10 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
Assert.Equal(4, chain!.Steps.Count);
|
||||
Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind);
|
||||
Assert.Equal("contract_handoff_token", chain.Steps[^1].Objectives[0].ItemId);
|
||||
Assert.NotNull(gather!.CompletionRewardBundle);
|
||||
Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants);
|
||||
Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId);
|
||||
Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -567,6 +605,110 @@ public class QuestDefinitionCatalogLoaderTests
|
|||
Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMissingCompletionRewardBundleOnFrozenQuest()
|
||||
{
|
||||
// Arrange
|
||||
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object");
|
||||
var gather = GetQuestRow(root, "prototype_quest_gather_intro");
|
||||
gather.Remove("completionRewardBundle");
|
||||
WriteCatalog(questsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("must include completionRewardBundle object", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenCompletionRewardBundleAmountDoesNotMatchFreezeTable()
|
||||
{
|
||||
// Arrange
|
||||
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object");
|
||||
var gather = GetQuestRow(root, "prototype_quest_gather_intro");
|
||||
var bundle = gather["completionRewardBundle"] as JsonObject
|
||||
?? throw new InvalidOperationException("expected bundle");
|
||||
var skillXpGrants = bundle["skillXpGrants"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected skillXpGrants");
|
||||
var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant");
|
||||
grant["amount"] = 99;
|
||||
WriteCatalog(questsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable()
|
||||
{
|
||||
// Arrange
|
||||
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object");
|
||||
var chain = GetQuestRow(root, "prototype_quest_operator_chain");
|
||||
var bundle = chain["completionRewardBundle"] as JsonObject
|
||||
?? throw new InvalidOperationException("expected bundle");
|
||||
var itemGrants = bundle["itemGrants"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected itemGrants");
|
||||
var grant = itemGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant");
|
||||
grant["itemId"] = "unknown_item_id";
|
||||
WriteCatalog(questsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||
// Assert — freeze gate runs before cross-ref (same order as validate_content.py)
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenCompletionRewardBundleSkillIdDoesNotMatchFreezeTable()
|
||||
{
|
||||
// Arrange
|
||||
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object");
|
||||
var gather = GetQuestRow(root, "prototype_quest_gather_intro");
|
||||
var bundle = gather["completionRewardBundle"] as JsonObject
|
||||
?? throw new InvalidOperationException("expected bundle");
|
||||
var skillXpGrants = bundle["skillXpGrants"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected skillXpGrants");
|
||||
var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant");
|
||||
grant["skillId"] = "unknown_skill_id";
|
||||
WriteCatalog(questsDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||
// Assert — freeze gate runs before cross-ref (same order as validate_content.py)
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSkillMissingMissionRewardAllowlist()
|
||||
{
|
||||
// Arrange
|
||||
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object");
|
||||
WriteCatalog(questsDir, root.ToJsonString());
|
||||
var salvageNoMissionReward = new SkillDefRow("salvage", "gather", "Salvage", ["activity"]);
|
||||
var skillDefs = new Dictionary<string, SkillDefRow>(RepoSkillDefs, StringComparer.Ordinal)
|
||||
{
|
||||
["salvage"] = salvageNoMissionReward,
|
||||
};
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, skillDefs));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMissingSchemaFile()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public sealed class QuestObjectiveWiringTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string AlphaNodeId = "prototype_resource_node_alpha";
|
||||
private const string DeltaNodeId = "prototype_urban_bulk_delta";
|
||||
private const string CombatEncounterId = "prototype_combat_pocket";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
|
||||
private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
|
||||
|
||||
[Fact]
|
||||
public async Task GatherIntro_ShouldComplete_WhenScrapGatheredThreeTimes()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.True(TryGather(deps, AlphaNodeId).Success);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefineIntro_ShouldComplete_WhenRefineScrapStandardCraftSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
AcceptAndMarkComplete(deps, GatherQuestId);
|
||||
Assert.True(TryAccept(deps, RefineQuestId).Success);
|
||||
SeedStack(deps, "scrap_metal_bulk", 5);
|
||||
|
||||
// Act
|
||||
var craft = TryCraft(deps, "refine_scrap_standard");
|
||||
|
||||
// Assert
|
||||
Assert.True(craft.Success);
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, RefineQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CombatIntro_ShouldComplete_WhenEncounterCompletes()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
Assert.True(TryAccept(deps, CombatQuestId).Success);
|
||||
|
||||
// Act
|
||||
QuestObjectiveWiring.TryProcessEncounterCompleteSuccess(
|
||||
PlayerId,
|
||||
CombatEncounterId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
|
||||
// Assert
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, CombatQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChainQuest_ShouldAdvanceThroughMixedObjectives_WhenPrerequisitesMet()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
AcceptAndMarkComplete(deps, GatherQuestId);
|
||||
AcceptAndMarkComplete(deps, RefineQuestId);
|
||||
CompleteCombatEncounter(deps);
|
||||
Assert.True(TryAccept(deps, ChainQuestId).Success);
|
||||
|
||||
// Act — step 0: gather 5 scrap (delta node yield)
|
||||
Assert.True(TryGather(deps, DeltaNodeId).Success);
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterGather));
|
||||
Assert.Equal(1, afterGather.CurrentStepIndex);
|
||||
|
||||
// Act — step 1: refine craft
|
||||
SeedStack(deps, "scrap_metal_bulk", 5);
|
||||
Assert.True(TryCraft(deps, "refine_scrap_standard").Success);
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterRefine));
|
||||
Assert.Equal(2, afterRefine.CurrentStepIndex);
|
||||
|
||||
// Act — step 2: field stim craft
|
||||
SeedStack(deps, "refined_plate_stock", 2);
|
||||
SeedStack(deps, "scrap_metal_bulk", 1);
|
||||
Assert.True(TryCraft(deps, "make_field_stim_mk0").Success);
|
||||
|
||||
// Assert — terminal inventory_has_item (contract_handoff_token from combat intro)
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var completed));
|
||||
Assert.Equal(QuestProgressStatus.Completed, completed.Status);
|
||||
Assert.Equal(3, completed.CurrentStepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatherIntro_ShouldCompleteInOneGather_WhenNodeYieldExceedsObjectiveQuantity()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
Assert.True(TryGather(deps, DeltaNodeId).Success);
|
||||
|
||||
// Assert
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, GatherQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
Assert.Equal(3, progress.ObjectiveCounters[GatherObjectiveId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InventoryHasItem_ShouldCompleteChainTerminalStep_WhenTokenAlreadyHeldOnStepAdvance()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
AcceptAndMarkComplete(deps, GatherQuestId);
|
||||
AcceptAndMarkComplete(deps, RefineQuestId);
|
||||
AcceptAndMarkComplete(deps, CombatQuestId);
|
||||
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
|
||||
Assert.True(TryAccept(deps, ChainQuestId).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
|
||||
Assert.True(QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
1,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
|
||||
Assert.True(QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
2,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
|
||||
|
||||
// Act
|
||||
var advance = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
3,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
|
||||
// Assert
|
||||
Assert.True(advance.Success);
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
}
|
||||
|
||||
private static void CompleteCombatEncounter(QuestWiringTestDependencies deps)
|
||||
{
|
||||
Assert.True(TryAccept(deps, CombatQuestId).Success);
|
||||
DefeatAllRequiredTargets(deps);
|
||||
var result = EncounterCompletionOperations.TryCompleteAndGrant(
|
||||
PlayerId,
|
||||
CombatEncounterId,
|
||||
deps.EncounterRegistry,
|
||||
deps.RewardTableRegistry,
|
||||
deps.EncounterProgressStore,
|
||||
deps.CompletionStore,
|
||||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider);
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
private static void DefeatAllRequiredTargets(QuestWiringTestDependencies deps)
|
||||
{
|
||||
foreach (var npcId in new[] { "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" })
|
||||
{
|
||||
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
|
||||
PlayerId,
|
||||
npcId,
|
||||
deps.EncounterRegistry,
|
||||
deps.EncounterProgressStore,
|
||||
deps.CompletionStore);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(
|
||||
PlayerId,
|
||||
npcId,
|
||||
deps.EncounterRegistry,
|
||||
deps.EncounterProgressStore,
|
||||
deps.CompletionStore);
|
||||
}
|
||||
}
|
||||
|
||||
private static QuestStateOperationResult TryAccept(QuestWiringTestDependencies deps, string questId) =>
|
||||
QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
|
||||
{
|
||||
Assert.True(TryAccept(deps, questId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider).Success);
|
||||
}
|
||||
|
||||
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
|
||||
GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
nodeId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
|
||||
CraftOperations.TryCraft(
|
||||
PlayerId,
|
||||
recipeId,
|
||||
quantity: 1,
|
||||
deps.RecipeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
|
||||
{
|
||||
var outcome = PlayerInventoryOperations.TryAddStack(
|
||||
PlayerId,
|
||||
itemId,
|
||||
quantity,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore);
|
||||
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
|
||||
}
|
||||
|
||||
private static QuestWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var services = factory.Services;
|
||||
return new QuestWiringTestDependencies(
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IResourceNodeDefinitionRegistry>(),
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>(),
|
||||
services.GetRequiredService<IRecipeDefinitionRegistry>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
|
||||
services.GetRequiredService<IEncounterProgressStore>(),
|
||||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private readonly record struct QuestWiringTestDependencies(
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IResourceNodeDefinitionRegistry NodeRegistry,
|
||||
IResourceNodeInstanceStore InstanceStore,
|
||||
IRecipeDefinitionRegistry RecipeRegistry,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore XpStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IRewardTableDefinitionRegistry RewardTableRegistry,
|
||||
IEncounterProgressStore EncounterProgressStore,
|
||||
IEncounterCompletionStore CompletionStore,
|
||||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||
|
||||
public sealed class QuestProgressApiTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string AlphaNodeId = "prototype_resource_node_alpha";
|
||||
private const string GatherQuestId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
|
||||
private const string RefineQuestId = PrototypeE7M1QuestCatalogRules.RefineIntroQuestId;
|
||||
private const string CombatQuestId = PrototypeE7M1QuestCatalogRules.CombatIntroQuestId;
|
||||
private const string ChainQuestId = PrototypeE7M1QuestCatalogRules.ChainQuestId;
|
||||
private const string GatherObjectiveId = PrototypeE7M1QuestCatalogRules.GatherIntroFirstObjectiveId;
|
||||
private const string ChainGatherObjectiveId = PrototypeE7M1QuestCatalogRules.ChainFirstObjectiveId;
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/players/missing-player/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/game/players/%20/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldReturnNotStartedForAllQuests_WhenNoProgress()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var registry = factory.Services.GetRequiredService<IQuestDefinitionRegistry>();
|
||||
var expectedQuestOrder = registry.GetDefinitionsInIdOrder().Select(static d => d.Id).ToArray();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(QuestProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.Equal(PlayerId, body.PlayerId);
|
||||
Assert.Equal(expectedQuestOrder.Length, body.Quests.Count);
|
||||
Assert.Equal(expectedQuestOrder, body.Quests.Select(static row => row.QuestId).ToArray());
|
||||
foreach (var row in body.Quests)
|
||||
{
|
||||
Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status);
|
||||
Assert.Equal(0, row.CurrentStepIndex);
|
||||
Assert.Empty(row.ObjectiveCounters);
|
||||
Assert.Null(row.CompletedAt);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldReturnActivePartialCounters_WhenGatherIntroInProgress()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var client = factory.CreateClient();
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
GatherObjectiveId,
|
||||
2,
|
||||
out _));
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(body);
|
||||
var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusActive, row.Status);
|
||||
Assert.Equal(0, row.CurrentStepIndex);
|
||||
Assert.Equal(2, row.ObjectiveCounters[GatherObjectiveId]);
|
||||
Assert.Null(row.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldReturnCompletedGatherIntro_WhenObjectivesSatisfied()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var client = factory.CreateClient();
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.True(TryGather(deps, AlphaNodeId).Success);
|
||||
}
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(body);
|
||||
var row = Assert.Single(body!.Quests, static r => r.QuestId == GatherQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
|
||||
Assert.Equal(0, row.CurrentStepIndex);
|
||||
Assert.NotNull(row.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldReturnCompletedChainAtTerminalStepIndex_WhenOperatorChainFinished()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var client = factory.CreateClient();
|
||||
CompleteOperatorChain(deps);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(body);
|
||||
var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
|
||||
Assert.Equal(3, row.CurrentStepIndex);
|
||||
Assert.NotNull(row.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQuestProgress_ShouldCompleteChainTerminal_WhenTokenHeldAndInventoryRefreshRunsOnGet()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var client = factory.CreateClient();
|
||||
AcceptAndMarkComplete(deps, GatherQuestId);
|
||||
AcceptAndMarkComplete(deps, RefineQuestId);
|
||||
AcceptAndMarkComplete(deps, CombatQuestId);
|
||||
SeedStack(deps, PrototypeE7M1QuestCatalogRules.ChainTerminalItemId, 1);
|
||||
Assert.True(TryAccept(deps, ChainQuestId).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainGatherObjectiveId, 5, out _));
|
||||
Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 1, out _));
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_refine", 1, out _));
|
||||
Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 2, out _));
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, "chain_obj_stim", 1, out _));
|
||||
Assert.True(deps.ProgressStore.TryAdvanceStep(PlayerId, ChainQuestId, 3, out _));
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var beforeGet));
|
||||
Assert.Equal(QuestProgressStatus.Active, beforeGet.Status);
|
||||
Assert.Equal(3, beforeGet.CurrentStepIndex);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/game/players/{PlayerId}/quest-progress");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<QuestProgressListResponse>();
|
||||
Assert.NotNull(body);
|
||||
var row = Assert.Single(body!.Quests, static r => r.QuestId == ChainQuestId);
|
||||
Assert.Equal(QuestProgressApi.StatusCompleted, row.Status);
|
||||
Assert.Equal(3, row.CurrentStepIndex);
|
||||
Assert.NotNull(row.CompletedAt);
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var afterGet));
|
||||
Assert.Equal(QuestProgressStatus.Completed, afterGet.Status);
|
||||
Assert.Equal(3, afterGet.CurrentStepIndex);
|
||||
Assert.NotNull(afterGet.CompletedAt);
|
||||
}
|
||||
|
||||
private static void CompleteOperatorChain(QuestWiringTestDependencies deps)
|
||||
{
|
||||
AcceptAndMarkComplete(deps, GatherQuestId);
|
||||
AcceptAndMarkComplete(deps, RefineQuestId);
|
||||
CompleteCombatEncounter(deps);
|
||||
Assert.True(TryAccept(deps, ChainQuestId).Success);
|
||||
Assert.True(TryGather(deps, "prototype_urban_bulk_delta").Success);
|
||||
SeedStack(deps, "scrap_metal_bulk", 5);
|
||||
Assert.True(TryCraft(deps, "refine_scrap_standard").Success);
|
||||
SeedStack(deps, "refined_plate_stock", 2);
|
||||
SeedStack(deps, "scrap_metal_bulk", 1);
|
||||
Assert.True(TryCraft(deps, "make_field_stim_mk0").Success);
|
||||
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, ChainQuestId, out var progress));
|
||||
Assert.Equal(QuestProgressStatus.Completed, progress.Status);
|
||||
Assert.Equal(3, progress.CurrentStepIndex);
|
||||
}
|
||||
|
||||
private static void CompleteCombatEncounter(QuestWiringTestDependencies deps)
|
||||
{
|
||||
Assert.True(TryAccept(deps, CombatQuestId).Success);
|
||||
DefeatAllRequiredTargets(deps);
|
||||
var result = EncounterCompletionOperations.TryCompleteAndGrant(
|
||||
PlayerId,
|
||||
"prototype_combat_pocket",
|
||||
deps.EncounterRegistry,
|
||||
deps.RewardTableRegistry,
|
||||
deps.EncounterProgressStore,
|
||||
deps.CompletionStore,
|
||||
deps.CompleteEventStore,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider);
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
private static void DefeatAllRequiredTargets(QuestWiringTestDependencies deps)
|
||||
{
|
||||
foreach (var npcId in new[] { "prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite" })
|
||||
{
|
||||
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
|
||||
PlayerId,
|
||||
npcId,
|
||||
deps.EncounterRegistry,
|
||||
deps.EncounterProgressStore,
|
||||
deps.CompletionStore);
|
||||
_ = EncounterProgressOperations.TryMarkTargetDefeated(
|
||||
PlayerId,
|
||||
npcId,
|
||||
deps.EncounterRegistry,
|
||||
deps.EncounterProgressStore,
|
||||
deps.CompletionStore);
|
||||
}
|
||||
}
|
||||
|
||||
private static QuestStateOperationResult TryAccept(QuestWiringTestDependencies deps, string questId) =>
|
||||
QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static void AcceptAndMarkComplete(QuestWiringTestDependencies deps, string questId)
|
||||
{
|
||||
Assert.True(TryAccept(deps, questId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider).Success);
|
||||
}
|
||||
|
||||
private static GatherResult TryGather(QuestWiringTestDependencies deps, string nodeId) =>
|
||||
GatherOperations.TryGather(
|
||||
PlayerId,
|
||||
nodeId,
|
||||
deps.NodeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.InstanceStore,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static CraftResult TryCraft(QuestWiringTestDependencies deps, string recipeId) =>
|
||||
CraftOperations.TryCraft(
|
||||
PlayerId,
|
||||
recipeId,
|
||||
quantity: 1,
|
||||
deps.RecipeRegistry,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.XpStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static void SeedStack(QuestWiringTestDependencies deps, string itemId, int quantity)
|
||||
{
|
||||
var outcome = PlayerInventoryOperations.TryAddStack(
|
||||
PlayerId,
|
||||
itemId,
|
||||
quantity,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore);
|
||||
Assert.Equal(PlayerInventoryMutationKind.Applied, outcome.Kind);
|
||||
}
|
||||
|
||||
private static QuestWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var services = factory.Services;
|
||||
return new QuestWiringTestDependencies(
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IResourceNodeDefinitionRegistry>(),
|
||||
services.GetRequiredService<IResourceNodeInstanceStore>(),
|
||||
services.GetRequiredService<IRecipeDefinitionRegistry>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IEncounterDefinitionRegistry>(),
|
||||
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
|
||||
services.GetRequiredService<IEncounterProgressStore>(),
|
||||
services.GetRequiredService<IEncounterCompletionStore>(),
|
||||
services.GetRequiredService<IEncounterCompleteEventStore>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private readonly record struct QuestWiringTestDependencies(
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IResourceNodeDefinitionRegistry NodeRegistry,
|
||||
IResourceNodeInstanceStore InstanceStore,
|
||||
IRecipeDefinitionRegistry RecipeRegistry,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore XpStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IEncounterDefinitionRegistry EncounterRegistry,
|
||||
IRewardTableDefinitionRegistry RewardTableRegistry,
|
||||
IEncounterProgressStore EncounterProgressStore,
|
||||
IEncounterCompletionStore CompletionStore,
|
||||
IEncounterCompleteEventStore CompleteEventStore,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Tests;
|
||||
|
|
@ -24,11 +25,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
|
|
@ -46,11 +43,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
RefineQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, RefineQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -65,7 +58,7 @@ public sealed class QuestStateOperationsTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
|
|
@ -74,11 +67,7 @@ public sealed class QuestStateOperationsTests
|
|||
timeProvider).Success);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
RefineQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, RefineQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
|
|
@ -94,11 +83,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
"prototype_quest_missing",
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, "prototype_quest_missing");
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -111,14 +96,10 @@ public sealed class QuestStateOperationsTests
|
|||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -134,7 +115,7 @@ public sealed class QuestStateOperationsTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
|
|
@ -143,11 +124,7 @@ public sealed class QuestStateOperationsTests
|
|||
timeProvider).Success);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -163,11 +140,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
UnknownPlayerId,
|
||||
RefineQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, UnknownPlayerId, RefineQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -182,11 +155,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
UnknownPlayerId,
|
||||
GatherQuestId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAccept(deps, UnknownPlayerId, GatherQuestId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -200,19 +169,14 @@ public sealed class QuestStateOperationsTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
AcceptAndComplete(deps, GatherQuestId, timeProvider);
|
||||
AcceptAndComplete(deps, RefineQuestId, timeProvider);
|
||||
AcceptAndComplete(deps, CombatQuestId, timeProvider);
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
AcceptAndComplete(deps, GatherQuestId);
|
||||
AcceptAndComplete(deps, RefineQuestId);
|
||||
AcceptAndComplete(deps, CombatQuestId);
|
||||
Assert.True(TryAccept(deps, ChainQuestId).Success);
|
||||
Assert.True(deps.ProgressStore.TryUpdateObjectiveCounter(PlayerId, ChainQuestId, ChainObjectiveId, 5, out _));
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
1,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAdvanceStep(deps, ChainQuestId, 1);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
|
|
@ -230,18 +194,13 @@ public sealed class QuestStateOperationsTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
AcceptAndComplete(deps, GatherQuestId, timeProvider);
|
||||
AcceptAndComplete(deps, RefineQuestId, timeProvider);
|
||||
AcceptAndComplete(deps, CombatQuestId, timeProvider);
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, ChainQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
AcceptAndComplete(deps, GatherQuestId);
|
||||
AcceptAndComplete(deps, RefineQuestId);
|
||||
AcceptAndComplete(deps, CombatQuestId);
|
||||
Assert.True(TryAccept(deps, ChainQuestId).Success);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
ChainQuestId,
|
||||
newStepIndex,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAdvanceStep(deps, ChainQuestId, newStepIndex);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -256,12 +215,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
"prototype_quest_missing",
|
||||
1,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAdvanceStep(deps, "prototype_quest_missing", 1);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -296,12 +250,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
1,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAdvanceStep(deps, GatherQuestId, 1);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -315,7 +264,7 @@ public sealed class QuestStateOperationsTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
|
|
@ -324,12 +273,7 @@ public sealed class QuestStateOperationsTests
|
|||
timeProvider).Success);
|
||||
|
||||
// Act
|
||||
var result = QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
1,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore);
|
||||
var result = TryAdvanceStep(deps, GatherQuestId, 1);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -343,7 +287,7 @@ public sealed class QuestStateOperationsTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var timeProvider = TimeProvider.System;
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
Assert.True(TryAccept(deps, GatherQuestId).Success);
|
||||
|
||||
// Act
|
||||
var first = QuestStateOperations.TryMarkComplete(
|
||||
|
|
@ -398,7 +342,7 @@ public sealed class QuestStateOperationsTests
|
|||
var deps = ResolveDependencies(factory);
|
||||
|
||||
// Act
|
||||
var accept = QuestStateOperations.TryAccept(PlayerId, GatherQuestId, deps.QuestRegistry, deps.ProgressStore);
|
||||
var accept = TryAccept(deps, GatherQuestId);
|
||||
var complete = QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
|
|
@ -436,26 +380,56 @@ public sealed class QuestStateOperationsTests
|
|||
Assert.True(canWrite);
|
||||
}
|
||||
|
||||
private static void AcceptAndComplete(
|
||||
(IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) deps,
|
||||
string questId,
|
||||
TimeProvider timeProvider)
|
||||
private static void AcceptAndComplete(QuestTestDependencies deps, string questId)
|
||||
{
|
||||
Assert.True(QuestStateOperations.TryAccept(PlayerId, questId, deps.QuestRegistry, deps.ProgressStore).Success);
|
||||
Assert.True(TryAccept(deps, questId).Success);
|
||||
Assert.True(QuestStateOperations.TryMarkComplete(
|
||||
PlayerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
timeProvider).Success);
|
||||
deps.TimeProvider).Success);
|
||||
}
|
||||
|
||||
private static (IQuestDefinitionRegistry QuestRegistry, IPlayerQuestStateStore ProgressStore) ResolveDependencies(
|
||||
InMemoryWebApplicationFactory factory)
|
||||
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string questId) =>
|
||||
TryAccept(deps, PlayerId, questId);
|
||||
|
||||
private static QuestStateOperationResult TryAccept(QuestTestDependencies deps, string playerId, string questId) =>
|
||||
QuestStateOperations.TryAccept(
|
||||
playerId,
|
||||
questId,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestStateOperationResult TryAdvanceStep(QuestTestDependencies deps, string questId, int newStepIndex) =>
|
||||
QuestStateOperations.TryAdvanceStep(
|
||||
PlayerId,
|
||||
questId,
|
||||
newStepIndex,
|
||||
deps.QuestRegistry,
|
||||
deps.ProgressStore,
|
||||
deps.InventoryStore,
|
||||
deps.ItemRegistry,
|
||||
deps.TimeProvider);
|
||||
|
||||
private static QuestTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var services = factory.Services;
|
||||
return (
|
||||
return new QuestTestDependencies(
|
||||
services.GetRequiredService<IQuestDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerQuestStateStore>());
|
||||
services.GetRequiredService<IPlayerQuestStateStore>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
private readonly record struct QuestTestDependencies(
|
||||
IQuestDefinitionRegistry QuestRegistry,
|
||||
IPlayerQuestStateStore ProgressStore,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
TimeProvider TimeProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Rewards;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Rewards;
|
||||
|
||||
public sealed class InMemoryRewardDeliveryStoreTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string QuestId = "prototype_quest_operator_chain";
|
||||
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void TryRecord_ShouldBeIdempotent_AtStoreLayer()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryRewardDeliveryStore();
|
||||
var deliveryEvent = CreatePrototypeEvent();
|
||||
// Act
|
||||
var first = store.TryRecord(deliveryEvent);
|
||||
var second = store.TryRecord(deliveryEvent with { DeliveredAt = DeliveredAt.AddHours(1) });
|
||||
var third = store.TryRecord(
|
||||
deliveryEvent with
|
||||
{
|
||||
PlayerId = "DEV-LOCAL-1",
|
||||
QuestId = "PROTOTYPE_QUEST_OPERATOR_CHAIN",
|
||||
});
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.False(second);
|
||||
Assert.False(third);
|
||||
Assert.True(store.TryGet(PlayerId, QuestId, out var stored));
|
||||
Assert.Equal(deliveryEvent.IdempotencyKey, stored.IdempotencyKey);
|
||||
Assert.Equal(DeliveredAt, stored.DeliveredAt);
|
||||
Assert.Single(stored.GrantedItems);
|
||||
Assert.Single(stored.GrantedSkillXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryRewardDeliveryStore();
|
||||
var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " };
|
||||
// Act
|
||||
var recorded = store.TryRecord(invalidEvent);
|
||||
// Assert
|
||||
Assert.False(recorded);
|
||||
Assert.False(store.TryGet(" ", QuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRecord_ShouldFailClosed_WhenQuestIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryRewardDeliveryStore();
|
||||
var invalidEvent = CreatePrototypeEvent() with { QuestId = " " };
|
||||
// Act
|
||||
var recorded = store.TryRecord(invalidEvent);
|
||||
// Assert
|
||||
Assert.False(recorded);
|
||||
Assert.False(store.TryGet(PlayerId, " ", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ShouldExposeReadableGrantSummary()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryRewardDeliveryStore();
|
||||
var deliveryEvent = CreatePrototypeEvent();
|
||||
// Act
|
||||
var recorded = store.TryRecord(deliveryEvent);
|
||||
var found = store.TryGet(PlayerId, QuestId, out var stored);
|
||||
// Assert
|
||||
Assert.True(recorded);
|
||||
Assert.True(found);
|
||||
Assert.Equal("survey_drone_kit", stored.GrantedItems[0].ItemId);
|
||||
Assert.Equal(1, stored.GrantedItems[0].Quantity);
|
||||
Assert.Equal("salvage", stored.GrantedSkillXp[0].SkillId);
|
||||
Assert.Equal(50, stored.GrantedSkillXp[0].Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveRewardDeliveryStoreFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
_ = await client.GetAsync("/health");
|
||||
var deliveryStore = factory.Services.GetRequiredService<IRewardDeliveryStore>();
|
||||
// Act
|
||||
var recorded = deliveryStore.TryRecord(CreatePrototypeEvent());
|
||||
var found = deliveryStore.TryGet(PlayerId, QuestId, out var stored);
|
||||
// Assert
|
||||
Assert.IsType<InMemoryRewardDeliveryStore>(deliveryStore);
|
||||
Assert.True(recorded);
|
||||
Assert.True(found);
|
||||
Assert.Equal(RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId), stored.IdempotencyKey);
|
||||
}
|
||||
|
||||
private static RewardDeliveryEvent CreatePrototypeEvent() =>
|
||||
new(
|
||||
PlayerId,
|
||||
QuestId,
|
||||
DeliveredAt,
|
||||
[new RewardItemGrantApplied("survey_drone_kit", 1)],
|
||||
[new RewardSkillXpGrantApplied("salvage", 50)],
|
||||
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, QuestId));
|
||||
}
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Rewards;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Rewards;
|
||||
|
||||
public sealed class RewardRouterOperationsTests
|
||||
{
|
||||
private const string PlayerId = "dev-local-1";
|
||||
private const string GatherQuestId = "prototype_quest_gather_intro";
|
||||
private const string OperatorChainQuestId = "prototype_quest_operator_chain";
|
||||
private const string SalvageScrapEfficiencyPerk = "salvage_scrap_efficiency_1";
|
||||
private static readonly DateTimeOffset DeliveredAt = new(2026, 6, 7, 14, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldApplyGatherIntroSkillXp_WhenBundleIsSkillOnly()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
var timeProvider = TimeProvider.System;
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
|
||||
// Act
|
||||
var first = Deliver(deps, GatherQuestId, bundle, timeProvider);
|
||||
var second = Deliver(deps, GatherQuestId, bundle, timeProvider);
|
||||
|
||||
// Assert
|
||||
Assert.True(first.Success);
|
||||
Assert.Null(first.ReasonCode);
|
||||
Assert.Empty(first.GrantedItems);
|
||||
Assert.Single(first.GrantedSkillXp);
|
||||
Assert.Equal("salvage", first.GrantedSkillXp[0].SkillId);
|
||||
Assert.Equal(25, first.GrantedSkillXp[0].Amount);
|
||||
Assert.NotNull(first.DeliveryEvent);
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
|
||||
Assert.Equal(first.DeliveryEvent!.Value.IdempotencyKey, stored.IdempotencyKey);
|
||||
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
||||
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
|
||||
Assert.Equal(salvageBefore + 25, salvageXp);
|
||||
Assert.True(second.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, second.ReasonCode);
|
||||
var xpAfterReplay = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
Assert.True(xpAfterReplay.TryGetValue("salvage", out var salvageAfterReplay));
|
||||
Assert.Equal(salvageBefore + 25, salvageAfterReplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldApplyItemAndSkillXp_WhenOperatorChainBundle()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Single(result.GrantedItems);
|
||||
Assert.Equal("survey_drone_kit", result.GrantedItems[0].ItemId);
|
||||
Assert.Equal(1, result.GrantedItems[0].Quantity);
|
||||
Assert.Single(result.GrantedSkillXp);
|
||||
Assert.Equal("salvage", result.GrantedSkillXp[0].SkillId);
|
||||
Assert.Equal(50, result.GrantedSkillXp[0].Amount);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
|
||||
Assert.Equal(1, CountBagItem(inventory!, "survey_drone_kit"));
|
||||
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
||||
Assert.True(xpAfter.TryGetValue("salvage", out var salvageXp));
|
||||
Assert.Equal(salvageBefore + 50, salvageXp);
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldDenyInventoryFull_WithoutStoreRecord()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
FillBag(deps);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.InventoryFull, result.ReasonCode);
|
||||
Assert.Empty(result.GrantedItems);
|
||||
Assert.Empty(result.GrantedSkillXp);
|
||||
Assert.Null(result.DeliveryEvent);
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldRollbackItems_WhenSkillApplyDenied()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 0)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.InvalidAmount, result.ReasonCode);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
Assert.Equal(
|
||||
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
||||
CountBagItem(afterInventory!, "survey_drone_kit"));
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldReturnExistingEvent_WhenAlreadyDelivered()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var existingEvent = new RewardDeliveryEvent(
|
||||
PlayerId,
|
||||
OperatorChainQuestId,
|
||||
DeliveredAt,
|
||||
[new RewardItemGrantApplied("survey_drone_kit", 1)],
|
||||
[new RewardSkillXpGrantApplied("salvage", 50)],
|
||||
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, OperatorChainQuestId));
|
||||
Assert.True(deps.DeliveryStore.TryRecord(existingEvent));
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
||||
Assert.Equal(existingEvent.DeliveredAt, result.DeliveredAt);
|
||||
Assert.Equal(existingEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
|
||||
Assert.Equal(
|
||||
CountBagItem(beforeInventory!, "survey_drone_kit"),
|
||||
CountBagItem(afterInventory!, "survey_drone_kit"));
|
||||
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldUseMissionRewardSourceKind_WhenCatalogAllowsIt()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("refine", 25)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, "prototype_quest_refine_intro", bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
var totals = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
Assert.True(totals.TryGetValue("refine", out var refineXp));
|
||||
Assert.Equal(25, refineXp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldDenyUnknownSkill_WhenSkillIdIsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)]);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.UnknownSkill, result.ReasonCode);
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldRecordEmptyDelivery_WhenBundleIsNull()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
|
||||
|
||||
// Act
|
||||
var result = Deliver(deps, GatherQuestId, bundle: null, TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Empty(result.GrantedItems);
|
||||
Assert.Empty(result.GrantedSkillXp);
|
||||
Assert.NotNull(result.DeliveryEvent);
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out var stored));
|
||||
Assert.Empty(stored.GrantedItems);
|
||||
Assert.Empty(stored.GrantedSkillXp);
|
||||
Assert.Equal(xpBefore, deps.SkillProgressionStore.GetXpTotals(PlayerId));
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
|
||||
Assert.Equal(TotalBagQuantity(inventoryBefore!), TotalBagQuantity(inventoryAfter!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldDenyInvalidPlayerId_WhenPlayerIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
|
||||
// Act
|
||||
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
||||
" ",
|
||||
GatherQuestId,
|
||||
bundle,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.InvalidPlayerId, result.ReasonCode);
|
||||
Assert.False(deps.DeliveryStore.TryGet(" ", GatherQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldDenyInvalidQuestId_WhenQuestIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
|
||||
// Act
|
||||
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
||||
PlayerId,
|
||||
" ",
|
||||
bundle,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.InvalidQuestId, result.ReasonCode);
|
||||
Assert.False(deps.DeliveryStore.TryGet(PlayerId, " ", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldRollbackGrants_WhenTryRecordLosesRace()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var winnerEvent = new RewardDeliveryEvent(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
DeliveredAt,
|
||||
[],
|
||||
[new RewardSkillXpGrantApplied("salvage", 25)],
|
||||
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
|
||||
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
||||
|
||||
// Act
|
||||
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
bundle,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
store,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
||||
Assert.Equal(winnerEvent.IdempotencyKey, result.DeliveryEvent!.Value.IdempotencyKey);
|
||||
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var salvageAfter = xpAfter.TryGetValue("salvage", out var afterSalvage) ? afterSalvage : 0;
|
||||
Assert.Equal(salvageBefore, salvageAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldRestorePerkState_WhenTryRecordLosesRaceAfterLevelUp()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425));
|
||||
var pick = deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency");
|
||||
Assert.True(pick.Success);
|
||||
var perkBefore = deps.PerkStore.GetSnapshot(PlayerId);
|
||||
Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkBefore.UnlockedPerkIds);
|
||||
var winnerEvent = new RewardDeliveryEvent(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
DeliveredAt,
|
||||
[],
|
||||
[new RewardSkillXpGrantApplied("salvage", 25)],
|
||||
RewardDeliveryIds.MakeIdempotencyKey(PlayerId, GatherQuestId));
|
||||
var store = new DeliveryStoreSimulatingRecordRaceLoss(winnerEvent);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
|
||||
// Act
|
||||
var result = RewardRouterOperations.TryDeliverQuestCompletion(
|
||||
PlayerId,
|
||||
GatherQuestId,
|
||||
bundle,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
store,
|
||||
TimeProvider.System);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(RewardDeliveryReasonCodes.AlreadyDelivered, result.ReasonCode);
|
||||
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
||||
Assert.Equal(425, salvageAfter);
|
||||
var perkAfter = deps.PerkStore.GetSnapshot(PlayerId);
|
||||
Assert.DoesNotContain(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds);
|
||||
Assert.Equal(perkBefore.UnlockedPerkIds, perkAfter.UnlockedPerkIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldNotDoubleGrant_WhenConcurrentRecordRaces()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
var bundle = new QuestRewardBundleRow(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryBefore);
|
||||
var xpBefore = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
var salvageBefore = xpBefore.TryGetValue("salvage", out var beforeSalvage) ? beforeSalvage : 0;
|
||||
var kitBefore = CountBagItem(inventoryBefore!, "survey_drone_kit");
|
||||
var barrier = new Barrier(2);
|
||||
RewardDeliveryResult result1 = default;
|
||||
RewardDeliveryResult result2 = default;
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(
|
||||
Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
result1 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
}),
|
||||
Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
result2 = Deliver(deps, OperatorChainQuestId, bundle, TimeProvider.System);
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventoryAfter);
|
||||
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
||||
Assert.Equal(salvageBefore + 25, salvageAfter);
|
||||
Assert.Equal(kitBefore + 1, CountBagItem(inventoryAfter!, "survey_drone_kit"));
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, OperatorChainQuestId, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryDeliverQuestCompletion_ShouldPreserveWinnerPerks_WhenConcurrentRecordRacesAfterLevelUp()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var deps = ResolveDependencies(factory);
|
||||
Assert.True(deps.SkillProgressionStore.TrySetSkillXpTotal(PlayerId, "salvage", 425));
|
||||
Assert.True(deps.PerkUnlockEngine.TrySelectBranch(PlayerId, "salvage", 1, "scrap_efficiency").Success);
|
||||
var bundle = new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)]);
|
||||
var barrier = new Barrier(2);
|
||||
RewardDeliveryResult result1 = default;
|
||||
RewardDeliveryResult result2 = default;
|
||||
|
||||
// Act
|
||||
await Task.WhenAll(
|
||||
Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
result1 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
||||
}),
|
||||
Task.Run(() =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
result2 = Deliver(deps, GatherQuestId, bundle, TimeProvider.System);
|
||||
}));
|
||||
|
||||
// Assert
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
var xpAfter = deps.SkillProgressionStore.GetXpTotals(PlayerId);
|
||||
Assert.True(xpAfter.TryGetValue("salvage", out var salvageAfter));
|
||||
Assert.Equal(450, salvageAfter);
|
||||
var perkAfter = deps.PerkStore.GetSnapshot(PlayerId);
|
||||
Assert.Contains(SalvageScrapEfficiencyPerk, perkAfter.UnlockedPerkIds);
|
||||
Assert.True(deps.DeliveryStore.TryGet(PlayerId, GatherQuestId, out _));
|
||||
}
|
||||
|
||||
private static RewardDeliveryResult Deliver(
|
||||
RewardRouterTestDependencies deps,
|
||||
string questId,
|
||||
QuestRewardBundleRow? bundle,
|
||||
TimeProvider timeProvider) =>
|
||||
RewardRouterOperations.TryDeliverQuestCompletion(
|
||||
PlayerId,
|
||||
questId,
|
||||
bundle,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore,
|
||||
deps.SkillRegistry,
|
||||
deps.SkillProgressionStore,
|
||||
deps.LevelCurve,
|
||||
deps.PerkUnlockEngine,
|
||||
deps.PerkStore,
|
||||
deps.DeliveryStore,
|
||||
timeProvider);
|
||||
|
||||
private static void FillBag(RewardRouterTestDependencies deps)
|
||||
{
|
||||
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
|
||||
{
|
||||
var add = PlayerInventoryOperations.TryAddStack(
|
||||
PlayerId,
|
||||
"survey_drone_kit",
|
||||
quantity: 1,
|
||||
deps.ItemRegistry,
|
||||
deps.InventoryStore);
|
||||
Assert.Equal(PlayerInventoryMutationKind.Applied, add.Kind);
|
||||
}
|
||||
}
|
||||
|
||||
private static RewardRouterTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var services = factory.Services;
|
||||
return new RewardRouterTestDependencies(
|
||||
services.GetRequiredService<IItemDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerInventoryStore>(),
|
||||
services.GetRequiredService<ISkillDefinitionRegistry>(),
|
||||
services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
services.GetRequiredService<ISkillLevelCurve>(),
|
||||
services.GetRequiredService<PerkUnlockEngine>(),
|
||||
services.GetRequiredService<IPlayerPerkStateStore>(),
|
||||
services.GetRequiredService<IRewardDeliveryStore>());
|
||||
}
|
||||
|
||||
private static int CountBagItem(PlayerInventorySnapshot snapshot, string itemId)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var slot in snapshot.BagSlots)
|
||||
{
|
||||
if (string.Equals(slot.ItemId, itemId, StringComparison.Ordinal))
|
||||
{
|
||||
total += slot.Quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static int TotalBagQuantity(PlayerInventorySnapshot snapshot)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var slot in snapshot.BagSlots)
|
||||
{
|
||||
if (!slot.IsEmpty)
|
||||
{
|
||||
total += slot.Quantity;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private sealed record RewardRouterTestDependencies(
|
||||
IItemDefinitionRegistry ItemRegistry,
|
||||
IPlayerInventoryStore InventoryStore,
|
||||
ISkillDefinitionRegistry SkillRegistry,
|
||||
IPlayerSkillProgressionStore SkillProgressionStore,
|
||||
ISkillLevelCurve LevelCurve,
|
||||
PerkUnlockEngine PerkUnlockEngine,
|
||||
IPlayerPerkStateStore PerkStore,
|
||||
IRewardDeliveryStore DeliveryStore);
|
||||
|
||||
/// <summary>TryGet misses until after a failed <see cref="IRewardDeliveryStore.TryRecord"/> — simulates concurrent race loser.</summary>
|
||||
private sealed class DeliveryStoreSimulatingRecordRaceLoss(RewardDeliveryEvent winnerEvent) : IRewardDeliveryStore
|
||||
{
|
||||
private bool recordAttempted;
|
||||
|
||||
public bool TryRecord(RewardDeliveryEvent deliveryEvent)
|
||||
{
|
||||
recordAttempted = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGet(string playerId, string questId, out RewardDeliveryEvent deliveryEvent)
|
||||
{
|
||||
if (!recordAttempted)
|
||||
{
|
||||
deliveryEvent = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
deliveryEvent = winnerEvent;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ using NeonSprawl.Server.Game.Gigs;
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
||||
|
|
@ -78,6 +79,8 @@ public static class AbilityCastApi
|
|||
IEncounterCompleteEventStore encounterCompleteEventStore,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore questProgressStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
|
|
@ -277,6 +280,8 @@ public static class AbilityCastApi
|
|||
encounterCompleteEventStore,
|
||||
itemRegistry,
|
||||
inventoryStore,
|
||||
questRegistry,
|
||||
questProgressStore,
|
||||
clock,
|
||||
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
|
@ -26,7 +27,10 @@ public static class CraftOperations
|
|||
ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore,
|
||||
ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine)
|
||||
PerkUnlockEngine perkUnlockEngine,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (quantity <= 0)
|
||||
{
|
||||
|
|
@ -153,6 +157,16 @@ public static class CraftOperations
|
|||
// (skillId, amount, sourceKind). No ingest or ILogger here (comments-only). Successful output grants also pass
|
||||
// through PlayerInventoryOperations (`item_created` hook, NEO-56). PlayerCraftApi delegates here (no duplicate hooks).
|
||||
|
||||
QuestObjectiveWiring.TryProcessCraftSuccess(
|
||||
playerId,
|
||||
recipeKey,
|
||||
quantity,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
timeProvider);
|
||||
|
||||
return new CraftResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
|
@ -16,7 +17,8 @@ public static class PlayerCraftApi
|
|||
IRecipeDefinitionRegistry recipeRegistry, IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine) =>
|
||||
PerkUnlockEngine perkUnlockEngine, IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != PlayerCraftRequest.CurrentSchemaVersion)
|
||||
{
|
||||
|
|
@ -45,7 +47,10 @@ public static class PlayerCraftApi
|
|||
skillRegistry,
|
||||
xpStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine);
|
||||
perkUnlockEngine,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
timeProvider);
|
||||
|
||||
if (!result.Success &&
|
||||
(string.Equals(result.ReasonCode, CraftReasonCodes.InventoryStoreMissing, StringComparison.Ordinal) ||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
|
|
@ -18,11 +19,13 @@ public static class EncounterCombatWiring
|
|||
bool targetDefeated,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IRewardTableDefinitionRegistry rewardTableRegistry,
|
||||
IEncounterProgressStore progressStore,
|
||||
IEncounterProgressStore encounterProgressStore,
|
||||
IEncounterCompletionStore completionStore,
|
||||
IEncounterCompleteEventStore completeEventStore,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore questProgressStore,
|
||||
TimeProvider timeProvider,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
|
|
@ -32,7 +35,7 @@ public static class EncounterCombatWiring
|
|||
playerId,
|
||||
targetNpcInstanceId,
|
||||
encounterRegistry,
|
||||
progressStore,
|
||||
encounterProgressStore,
|
||||
completionStore);
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +48,7 @@ public static class EncounterCombatWiring
|
|||
playerId,
|
||||
targetNpcInstanceId,
|
||||
encounterRegistry,
|
||||
progressStore,
|
||||
encounterProgressStore,
|
||||
completionStore))
|
||||
{
|
||||
return;
|
||||
|
|
@ -64,7 +67,7 @@ public static class EncounterCombatWiring
|
|||
playerId,
|
||||
encounterId,
|
||||
encounterRegistry,
|
||||
progressStore))
|
||||
encounterProgressStore))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -74,11 +77,13 @@ public static class EncounterCombatWiring
|
|||
encounterId,
|
||||
encounterRegistry,
|
||||
rewardTableRegistry,
|
||||
progressStore,
|
||||
encounterProgressStore,
|
||||
completionStore,
|
||||
completeEventStore,
|
||||
itemRegistry,
|
||||
inventoryStore,
|
||||
questRegistry,
|
||||
questProgressStore,
|
||||
timeProvider);
|
||||
|
||||
if (!completion.Success)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Encounters;
|
||||
|
||||
|
|
@ -26,6 +27,8 @@ public static class EncounterCompletionOperations
|
|||
IEncounterCompleteEventStore completeEventStore,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore questProgressStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
|
||||
|
|
@ -135,6 +138,15 @@ public static class EncounterCompletionOperations
|
|||
// Planned payload: playerId, encounterId, rewardTableId, grantedItems[] (EncounterGrantApplied batch).
|
||||
// Inventory layer still emits item_created per stack via PlayerInventoryOperations (NEO-56).
|
||||
|
||||
QuestObjectiveWiring.TryProcessEncounterCompleteSuccess(
|
||||
normalizedPlayerId,
|
||||
normalizedEncounterId,
|
||||
questRegistry,
|
||||
questProgressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
timeProvider);
|
||||
|
||||
return new EncounterCompletionResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Gathering;
|
||||
|
|
@ -27,7 +28,10 @@ public static class GatherOperations
|
|||
IPlayerSkillProgressionStore xpStore,
|
||||
ISkillLevelCurve levelCurve,
|
||||
PerkUnlockEngine perkUnlockEngine,
|
||||
IResourceNodeInstanceStore instanceStore)
|
||||
IResourceNodeInstanceStore instanceStore,
|
||||
IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var key = ResourceNodeInstanceIds.Normalize(interactableId);
|
||||
if (key.Length == 0 ||
|
||||
|
|
@ -128,6 +132,16 @@ public static class GatherOperations
|
|||
// (last gatherer), maxGathers from definition when wiring E9.M1.
|
||||
}
|
||||
|
||||
QuestObjectiveWiring.TryProcessGatherSuccess(
|
||||
playerId,
|
||||
yield.ItemId,
|
||||
yield.Quantity,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
timeProvider);
|
||||
|
||||
return new GatherResult(
|
||||
Success: true,
|
||||
ReasonCode: null,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using NeonSprawl.Server.Game.Gathering;
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
||||
|
|
@ -21,7 +22,8 @@ public static class InteractionApi
|
|||
IResourceNodeDefinitionRegistry nodeRegistry, IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore, ISkillDefinitionRegistry skillRegistry,
|
||||
IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve, PerkUnlockEngine perkUnlockEngine,
|
||||
IResourceNodeInstanceStore nodeInstanceStore) =>
|
||||
IResourceNodeInstanceStore nodeInstanceStore, IQuestDefinitionRegistry questRegistry,
|
||||
IPlayerQuestStateStore progressStore, TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
||||
{
|
||||
|
|
@ -86,7 +88,10 @@ public static class InteractionApi
|
|||
xpStore,
|
||||
levelCurve,
|
||||
perkUnlockEngine,
|
||||
nodeInstanceStore);
|
||||
nodeInstanceStore,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
timeProvider);
|
||||
|
||||
if (!gather.Success)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,9 +14,15 @@ public interface IPlayerPerkStateStore
|
|||
/// <summary>Adds perks not already unlocked; fails when player cannot be written.</summary>
|
||||
bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds);
|
||||
|
||||
/// <summary>Removes unlocked perks present in the store; fails when player cannot be written.</summary>
|
||||
bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> perkIds);
|
||||
|
||||
/// <summary>Removes a branch pick (engine rollback when perk unlock fails after pick).</summary>
|
||||
bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex);
|
||||
|
||||
/// <summary>Clears all branch picks and unlocked perks for the player (NEO-48 dev fixture API).</summary>
|
||||
bool TryResetPerkState(string playerId);
|
||||
|
||||
/// <summary>Replaces branch picks and unlocked perks with <paramref name="snapshot"/> (grant rollback).</summary>
|
||||
bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,32 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> perkIds)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
foreach (var raw in perkIds)
|
||||
{
|
||||
var pid = raw.Trim();
|
||||
if (pid.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
inner.RemoveUnlockedPerk(pid);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
|
||||
{
|
||||
|
|
@ -123,6 +149,35 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
inner.Reset();
|
||||
foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId)
|
||||
{
|
||||
foreach (var (tierIndex, branchId) in tiers)
|
||||
{
|
||||
inner.SetBranchPick(skillId, tierIndex, branchId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var perkId in snapshot.UnlockedPerkIds)
|
||||
{
|
||||
inner.AddUnlockedPerk(perkId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
|
|
@ -156,6 +211,8 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
|
|||
|
||||
public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId);
|
||||
|
||||
public void RemoveUnlockedPerk(string perkId) => unlockedPerks.Remove(perkId);
|
||||
|
||||
public bool RemoveBranchPick(string skillId, int tierIndex) =>
|
||||
branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex);
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,45 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRemoveUnlockedPerks(string playerId, IEnumerable<string> perkIds)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, norm, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var raw in perkIds)
|
||||
{
|
||||
var perkId = raw.Trim();
|
||||
if (perkId.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var delete = new Npgsql.NpgsqlCommand(
|
||||
"DELETE FROM player_unlocked_perk WHERE player_id = @pid AND perk_id = @perk;",
|
||||
conn,
|
||||
tx);
|
||||
delete.Parameters.AddWithValue("pid", norm);
|
||||
delete.Parameters.AddWithValue("perk", perkId);
|
||||
delete.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
|
||||
{
|
||||
|
|
@ -257,6 +296,79 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRestoreSnapshot(string playerId, PerkStateSnapshot snapshot)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var tx = conn.BeginTransaction();
|
||||
if (!PlayerExists(conn, norm, tx))
|
||||
{
|
||||
tx.Rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
using (var picks = new Npgsql.NpgsqlCommand(
|
||||
"DELETE FROM player_mastery_branch_pick WHERE player_id = @pid;",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
picks.Parameters.AddWithValue("pid", norm);
|
||||
picks.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using (var perks = new Npgsql.NpgsqlCommand(
|
||||
"DELETE FROM player_unlocked_perk WHERE player_id = @pid;",
|
||||
conn,
|
||||
tx))
|
||||
{
|
||||
perks.Parameters.AddWithValue("pid", norm);
|
||||
perks.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId)
|
||||
{
|
||||
foreach (var (tierIndex, branchId) in tiers)
|
||||
{
|
||||
using var insert = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at)
|
||||
VALUES (@pid, @sid, @tier, @bid, now());
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
insert.Parameters.AddWithValue("pid", norm);
|
||||
insert.Parameters.AddWithValue("sid", skillId);
|
||||
insert.Parameters.AddWithValue("tier", tierIndex);
|
||||
insert.Parameters.AddWithValue("bid", branchId);
|
||||
insert.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var perkId in snapshot.UnlockedPerkIds)
|
||||
{
|
||||
using var upsert = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at)
|
||||
VALUES (@pid, @perk, now());
|
||||
""",
|
||||
conn,
|
||||
tx);
|
||||
upsert.Parameters.AddWithValue("pid", norm);
|
||||
upsert.Parameters.AddWithValue("perk", perkId);
|
||||
upsert.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
tx.Commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
using System.Collections.Frozen;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E7M2 completion bundle gates (NEO-125), mirrored from
|
||||
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_*</c> and <c>_prototype_e7m2_*</c> gate functions.
|
||||
/// </summary>
|
||||
public static class PrototypeE7M2QuestCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND</c>.</summary>
|
||||
public const string MissionRewardSourceKind = "mission_reward";
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_COMPLETION_BUNDLES</c>.</summary>
|
||||
public static readonly FrozenDictionary<string, QuestRewardBundleRow> ExpectedCompletionBundles =
|
||||
new Dictionary<string, QuestRewardBundleRow>(StringComparer.Ordinal)
|
||||
{
|
||||
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new(
|
||||
[],
|
||||
[new QuestSkillXpGrantRow("salvage", 25)]),
|
||||
[PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new(
|
||||
[],
|
||||
[new QuestSkillXpGrantRow("refine", 25)]),
|
||||
[PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new(
|
||||
[],
|
||||
[new QuestSkillXpGrantRow("salvage", 25)]),
|
||||
[PrototypeE7M1QuestCatalogRules.ChainQuestId] = new(
|
||||
[new RewardGrantRow("survey_drone_kit", 1)],
|
||||
[new QuestSkillXpGrantRow("salvage", 50)]),
|
||||
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetCompletionBundlePresenceError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
|
||||
{
|
||||
foreach (var qid in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal))
|
||||
{
|
||||
if (!rowsById.TryGetValue(qid, out var row))
|
||||
return $"error: missing quest '{qid}'";
|
||||
|
||||
if (row.CompletionRewardBundle is null)
|
||||
return $"error: quest '{qid}' must include completionRewardBundle object";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error if bundle contents diverge from the E7M2 freeze table, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
|
||||
{
|
||||
foreach (var (qid, expected) in ExpectedCompletionBundles)
|
||||
{
|
||||
if (!rowsById.TryGetValue(qid, out var row))
|
||||
return $"error: missing quest '{qid}'";
|
||||
|
||||
if (row.CompletionRewardBundle is null)
|
||||
return $"error: quest '{qid}' must include completionRewardBundle object";
|
||||
|
||||
var actual = NormalizeBundle(row.CompletionRewardBundle);
|
||||
var expectedNormalized = NormalizeBundle(expected);
|
||||
if (!BundlesEqual(actual, expectedNormalized))
|
||||
{
|
||||
return
|
||||
$"error: quest '{qid}' completionRewardBundle must match E7M2 freeze table " +
|
||||
$"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable error when bundle cross-refs fail, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetCompletionBundleCrossRefError(
|
||||
IReadOnlyDictionary<string, QuestDefRow> rowsById,
|
||||
IReadOnlyDictionary<string, SkillDefRow> skillDefsById)
|
||||
{
|
||||
foreach (var qid in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal))
|
||||
{
|
||||
if (!rowsById.TryGetValue(qid, out var row) || row.CompletionRewardBundle is null)
|
||||
continue;
|
||||
|
||||
var bundle = row.CompletionRewardBundle;
|
||||
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.ItemGrants[gi];
|
||||
if (!PrototypeSlice1ItemCatalogRules.ExpectedItemIds.Contains(grant.ItemId))
|
||||
{
|
||||
return
|
||||
$"error: quest '{qid}' completionRewardBundle.itemGrants[{gi}]: itemId " +
|
||||
$"'{grant.ItemId}' is not in the frozen prototype item catalog";
|
||||
}
|
||||
}
|
||||
|
||||
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
|
||||
{
|
||||
var grant = bundle.SkillXpGrants[gi];
|
||||
if (!PrototypeSlice1SkillCatalogRules.ExpectedSkillIds.Contains(grant.SkillId))
|
||||
{
|
||||
return
|
||||
$"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
|
||||
$"'{grant.SkillId}' is not in the frozen prototype skill catalog";
|
||||
}
|
||||
|
||||
skillDefsById.TryGetValue(grant.SkillId, out var skill);
|
||||
var allowedKinds = skill?.AllowedXpSourceKinds ?? [];
|
||||
if (!allowedKinds.Contains(MissionRewardSourceKind, StringComparer.Ordinal))
|
||||
{
|
||||
var kindsDisplay = allowedKinds.Count == 0
|
||||
? "[]"
|
||||
: $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]";
|
||||
return
|
||||
$"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
|
||||
$"'{grant.SkillId}' must allow sourceKind '{MissionRewardSourceKind}' in allowedXpSourceKinds " +
|
||||
$"(got {kindsDisplay})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
|
||||
new(
|
||||
bundle.ItemGrants
|
||||
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Quantity)
|
||||
.ToArray(),
|
||||
bundle.SkillXpGrants
|
||||
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Amount)
|
||||
.ToArray());
|
||||
|
||||
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
|
||||
{
|
||||
if (left.ItemGrants.Length != right.ItemGrants.Length ||
|
||||
left.SkillXpGrants.Length != right.SkillXpGrants.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.ItemGrants.Length; i++)
|
||||
{
|
||||
if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId ||
|
||||
left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < left.SkillXpGrants.Length; i++)
|
||||
{
|
||||
if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId ||
|
||||
left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatBundle(NormalizedBundle bundle)
|
||||
{
|
||||
var itemGrants = string.Join(
|
||||
", ",
|
||||
bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}"));
|
||||
var skillXpGrants = string.Join(
|
||||
", ",
|
||||
bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}"));
|
||||
return
|
||||
$"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}] }}";
|
||||
}
|
||||
|
||||
private sealed record NormalizedBundle(
|
||||
RewardGrantRow[] ItemGrants,
|
||||
QuestSkillXpGrantRow[] SkillXpGrants);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Maps <c>POST /game/players/{{id}}/quests/{{questId}}/accept</c> (NEO-120).</summary>
|
||||
public static class QuestAcceptApi
|
||||
{
|
||||
public static WebApplication MapQuestAcceptApi(this WebApplication app)
|
||||
{
|
||||
app.MapPost(
|
||||
"/game/players/{id}/quests/{questId}/accept",
|
||||
(string id, string questId, QuestAcceptRequest? body, IPositionStateStore positions,
|
||||
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
|
||||
IPlayerInventoryStore inventoryStore, IItemDefinitionRegistry itemRegistry,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
if (body is not null &&
|
||||
body.SchemaVersion != 0 &&
|
||||
body.SchemaVersion != QuestAcceptRequest.CurrentSchemaVersion)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var trimmedId = id.Trim();
|
||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var result = QuestStateOperations.TryAccept(
|
||||
trimmedId,
|
||||
questId,
|
||||
questRegistry,
|
||||
progressStore,
|
||||
inventoryStore,
|
||||
itemRegistry,
|
||||
timeProvider);
|
||||
|
||||
return Results.Json(MapResponse(result));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static QuestAcceptResponse MapResponse(QuestStateOperationResult result)
|
||||
{
|
||||
QuestProgressRowJson? quest = null;
|
||||
if (result.Snapshot is { } snapshot)
|
||||
{
|
||||
quest = QuestProgressApi.MapQuestProgressRow(snapshot);
|
||||
}
|
||||
|
||||
return new QuestAcceptResponse
|
||||
{
|
||||
Accepted = result.Success,
|
||||
ReasonCode = result.ReasonCode,
|
||||
Quest = quest,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Quests;
|
||||
|
||||
/// <summary>Optional POST body for <c>POST /game/players/{{id}}/quests/{{questId}}/accept</c> (NEO-120).</summary>
|
||||
public sealed class QuestAcceptRequest
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>POST response for quest accept (NEO-120).</summary>
|
||||
public sealed class QuestAcceptResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
|
||||
[JsonPropertyName("accepted")]
|
||||
public bool Accepted { get; init; }
|
||||
|
||||
[JsonPropertyName("reasonCode")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? ReasonCode { get; init; }
|
||||
|
||||
[JsonPropertyName("quest")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public QuestProgressRowJson? Quest { get; init; }
|
||||
}
|
||||
|
|
@ -93,4 +93,16 @@ public static class QuestCatalogPathResolution
|
|||
|
||||
return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json"));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a reward grant row (shared by reward tables and quest bundles).</summary>
|
||||
public static string ResolveRewardGrantRowSchemaPath(string questsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "reward-grant-row.schema.json"));
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a quest skill XP grant row.</summary>
|
||||
public static string ResolveQuestSkillXpGrantSchemaPath(string questsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json"));
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a quest completion reward bundle.</summary>
|
||||
public static string ResolveQuestRewardBundleSchemaPath(string questsDirectory) =>
|
||||
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-reward-bundle.schema.json"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public static class QuestCatalogServiceCollectionExtensions
|
|||
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
|
||||
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
||||
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
|
||||
|
||||
|
|
@ -42,6 +43,9 @@ public static class QuestCatalogServiceCollectionExtensions
|
|||
questsDir,
|
||||
opts.QuestObjectiveDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
var rewardGrantRowSchemaPath = QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir);
|
||||
var questSkillXpGrantSchemaPath = QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir);
|
||||
var questRewardBundleSchemaPath = QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir);
|
||||
|
||||
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
|
|
@ -52,9 +56,13 @@ public static class QuestCatalogServiceCollectionExtensions
|
|||
questDefSchemaPath,
|
||||
questStepDefSchemaPath,
|
||||
questObjectiveDefSchemaPath,
|
||||
rewardGrantRowSchemaPath,
|
||||
questSkillXpGrantSchemaPath,
|
||||
questRewardBundleSchemaPath,
|
||||
knownItemIds,
|
||||
knownRecipeIds,
|
||||
knownEncounterIds,
|
||||
skillCatalog.ById,
|
||||
logger);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ public sealed class QuestDefRow(
|
|||
string id,
|
||||
string displayName,
|
||||
IReadOnlyList<string> prerequisiteQuestIds,
|
||||
IReadOnlyList<QuestStepDefRow> steps)
|
||||
IReadOnlyList<QuestStepDefRow> steps,
|
||||
QuestRewardBundleRow? completionRewardBundle = null)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
|
||||
|
|
@ -14,4 +15,6 @@ public sealed class QuestDefRow(
|
|||
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
|
||||
|
||||
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
|
||||
|
||||
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue