Compare commits

...

76 Commits

Author SHA1 Message Date
VinPropane 4e5d92b6c8
Merge pull request #166 from ViPro-Technologies/NEO-127-e7m2-reward-router-operations
NEO-127: E7M2-04 RewardRouterOperations (apply QuestRewardBundle)
2026-06-07 18:14:54 -04:00
VinPropane 039672cdac NEO-127: avoid clobbering winner perks on TryRecord race rollback
Track perks unlocked per grant and remove only those on race loss instead
of restoring a full pre-apply snapshot; re-run ReevaluateAfterLevelUp at
post-rollback levels so a concurrent winner's path-auto unlocks survive.
2026-06-07 18:10:56 -04:00
VinPropane be9bb8f961 NEO-127: restore perk state on TryRecord race rollback
Capture pre-skill-apply XP and perk snapshots; on TryRecord loss reverse
each applied skill row via negative delta and restore the perk snapshot so
level-up unlocks from the loser call are reverted without clobbering a
concurrent winner's XP.
2026-06-07 18:04:06 -04:00
VinPropane 71fbf3d628 NEO-127: rollback duplicate grants when TryRecord loses race
Mirror EncounterCompletionOperations compensating rollback: concurrent
losers undo item and skill XP applies before returning the winner event.
2026-06-07 17:53:20 -04:00
VinPropane c764f5af6b NEO-127: update review summary after follow-up tests 2026-06-07 17:49:34 -04:00
VinPropane 65ca11171b NEO-127: address code review test and backlog doc findings
Add null-bundle and invalid-id deny tests, fix replay XP assertion,
align E7M2-04 backlog wording with TryApplyGrant, strike review items done.
2026-06-07 17:49:26 -04:00
VinPropane 581c90ab34 NEO-127: add code review for reward router operations 2026-06-07 17:48:42 -04:00
VinPropane 5b84ffd75a NEO-127: add RewardRouterOperations for quest completion bundles
Apply item grants and mission_reward skill XP with compensating rollback,
idempotent IRewardDeliveryStore recording, and AAA integration tests.
2026-06-07 17:47:32 -04:00
VinPropane 35b8904f15 NEO-127: add E7M2-04 reward router operations implementation plan
Kickoff plan for RewardRouterOperations apply path building on NEO-125
catalog bundles and NEO-126 delivery store idempotency.
2026-06-07 17:45:01 -04:00
VinPropane ce142b9271
Merge pull request #165 from ViPro-Technologies/NEO-126-e7m2-reward-delivery-store
NEO-126: E7M2-03 reward delivery store (IRewardDeliveryStore)
2026-06-07 17:36:59 -04:00
VinPropane 35fa4ad192 NEO-126: address code review — module anchor and casing test
Add E7.M2 implementation anchor for the delivery store and assert
mixed-case TryRecord replays are rejected at the store layer.
2026-06-07 17:33:07 -04:00
VinPropane 3d8df939b6 NEO-126: add code review for reward delivery store 2026-06-07 17:32:37 -04:00
VinPropane 2a8fe31b14 NEO-126: add IRewardDeliveryStore for quest completion rewards
In-memory RewardDeliveryEvent store with idempotent TryRecord,
grant summaries for future HTTP projection, DI registration, and tests.
2026-06-07 17:31:45 -04:00
VinPropane b20d9c18b5 NEO-126: add E7M2-03 reward delivery store implementation plan
Kickoff plan for IRewardDeliveryStore — in-memory idempotent
RewardDeliveryEvent records keyed by quest completion.
2026-06-07 17:30:14 -04:00
VinPropane 0372991d87
Merge pull request #164 from ViPro-Technologies/NEO-125-e7m2-server-quest-catalog-reward-bundle-validation
NEO-125: E7M2-02 server quest catalog reward bundle validation
2026-06-07 15:32:01 -04:00
VinPropane 766837d7e8 NEO-125: address code review findings for bundle validation
Rename freeze-first loader tests, add PrototypeE7M2QuestCatalogRulesTests
for cross-ref paths, align missing skill-def error with Python, and
document bundle schema path resolution in server README.
2026-06-07 15:27:55 -04:00
VinPropane ad80ed8760 NEO-125: add code review for quest bundle catalog load 2026-06-07 15:26:51 -04:00
VinPropane 8d026bdca4 NEO-125: document server bundle load in README and alignment docs 2026-06-07 15:25:58 -04:00
VinPropane 3924695619 NEO-125: fail-fast quest completionRewardBundle validation at startup
Register bundle JSON Schemas, parse bundle DTOs on QuestDefRow, and run
E7M2 prototype gates (presence, freeze table, cross-ref, mission_reward)
with skill catalog input from DI.
2026-06-07 15:25:58 -04:00
VinPropane 47ddde671f NEO-125: add implementation plan for quest bundle catalog load 2026-06-07 15:23:21 -04:00
VinPropane 0ae6bea327
Merge pull request #163 from ViPro-Technologies/NEO-124-e7m2-questrewardbundle-schema-quest-catalog-extension-ci
NEO-124: E7M2-01 quest completion reward bundle schema + CI
2026-06-07 15:20:55 -04:00
VinPropane d3c7dbb1ad NEO-124: Fix E7.M2 alignment register row and close review follow-up.
Repair corrupted References column; document negative CI spot-check results in review.
2026-06-07 15:17:24 -04:00
VinPropane fdd1fe1536 NEO-124: Add code review for quest completion bundle CI slice. 2026-06-07 15:16:34 -04:00
VinPropane c8e7449756 NEO-124: Document quest completion bundle CI and reconcile plan.
Update E7.M2 module, content README, CT.M1, alignment register, and E7M2 backlog for landed catalog slice.
2026-06-07 15:15:53 -04:00
VinPropane afcf0fba28 NEO-124: Add quest completion reward bundle schemas, catalog, and CI gates.
Extend quest defs with completionRewardBundle on four frozen quests; validate bundle freeze table and item/skill cross-refs in validate_content.py.
2026-06-07 15:15:52 -04:00
VinPropane 98a9602bb0 NEO-124: Add E7M2-01 implementation plan (quest reward bundle schema + CI).
Lock completionRewardBundle content shape, kickoff decisions, and file lists before catalog/validator work.
2026-06-07 15:13:42 -04:00
VinPropane 85ead01a0b chore: Decompose E7.M2 prototype backlog (NEO-124–NEO-132).
Epic 7 Slice 2 reward router: nine server+client stories, Linear
issues, module docs, and kickoff defaults for completion bundles.
2026-06-07 15:11:43 -04:00
VinPropane 31609917a5
Merge pull request #162 from ViPro-Technologies/NEO-123-playable-onboarding-quest-chain-capstone
NEO-123: Playable onboarding quest chain capstone (Godot)
2026-06-07 15:09:03 -04:00
VinPropane f4e9f62533 NEO-123: Record capstone QA pass and mark E7.M1 Ready.
Human QA passed; update acceptance checkboxes, module register, and
alignment docs for Epic 7 Slice 1 client capstone completion.
2026-06-07 15:07:59 -04:00
VinPropane ec71fba260 NEO-123: Add capstone manual QA and onboarding quest loop docs.
Ship four-quest Godot session script (Shift+Q catalog accept order,
idempotency steps), client README capstone section, and align NEO-122
component QA with catalog-order picker behavior.
2026-06-07 15:03:29 -04:00
VinPropane 4cfec3ac7f NEO-123: Add implementation plan for quest chain capstone.
Kickoff clarifications adopt docs-primary scope, Shift+Q catalog
accept order, and Godot-restart idempotency per NEO-111 precedent.
2026-06-07 15:01:34 -04:00
VinPropane 316966f447
Merge pull request #161 from ViPro-Technologies/NEO-122-client-quest-progress-accept-hud
NEO-122: Client quest progress and accept HUD (Godot)
2026-06-07 14:58:58 -04:00
VinPropane 803d66ea29 NEO-122: Document quest refresh on encounter completed GET. 2026-06-07 14:56:46 -04:00
VinPropane 5e146cd934 NEO-122: Surface quest accept in-flight and failed-start HUD feedback.
Show sending/busy/could-not-start copy on Q and Shift+Q accept paths and
lock the behavior with GdUnit key-input tests.
2026-06-07 14:55:47 -04:00
VinPropane bada3024c2 NEO-122: Repaint quest HUD when definitions load.
Definitions ready now re-renders progress rows when a snapshot exists
even after a progress sync error, so display names replace raw ids.
2026-06-07 14:52:01 -04:00
VinPropane c663721d34 NEO-122: Harden quest HUD error/accept rendering.
Keep accept merge visible after sync failure, clear stale progress
error on successful accept, show sync banner with rows, and block
Shift+Q when definitions failed to load.
2026-06-07 14:49:12 -04:00
VinPropane 91bdb18357 NEO-122: Keep quest HUD fresh after accept.
Merge accept-response quest row into the HUD snapshot immediately,
re-apply when a stale GET returns not_started, and queue pending sync
when progress GET is already in flight.
2026-06-07 14:45:56 -04:00
VinPropane bfded69b3b NEO-122: gdformat quest_hud_controller_test.gd. 2026-06-07 14:42:45 -04:00
VinPropane 46e24d35d6 NEO-122: Fix bugbot items and GdUnit orphan leaks.
Block Shift+Q when progress sync failed; remove HudRootScroll 80px min
that overlapped the economy panel; rewrite quest_hud_controller tests
with HTTP mocks and auto_free for CI exit 0.
2026-06-07 14:42:18 -04:00
VinPropane 3606d0ba57 NEO-122: Fix CI flake and address Bugbot quest HUD feedback.
Harden npc windup timing test, refresh quest progress on encounter
complete, surface accept busy/loading feedback, and treat missing quest
rows as not_started for Shift+Q eligibility.
2026-06-07 14:38:06 -04:00
VinPropane 2514772b43 NEO-122: Fix headless GdUnit failures in quest and economy tests.
Add HTTP clients to the scene tree before quest HUD setup, set a stable
1280x720 viewport for economy layout tests, and include RecipesHeaderLabel
in the test section scaffold.
2026-06-07 13:45:10 -04:00
VinPropane 6397d81ca3 NEO-122: Run gdformat on quest and economy HUD client scripts. 2026-06-07 13:37:58 -04:00
VinPropane 2bf4d325e1 NEO-122: Fix quest HUD controller test line length for gdlint. 2026-06-07 13:37:43 -04:00
VinPropane b3f9b22ef2 NEO-122: Shorten economy HUD paths and add quest HUD controller tests. 2026-06-07 13:37:39 -04:00
VinPropane eba0d09d70 NEO-122: Extract quest HUD controller to satisfy gdlint limits.
Move quest progress/accept wiring out of main.gd so pre-push gdlint
passes max line length and max file lines checks.
2026-06-07 13:37:25 -04:00
VinPropane ba75aeed79 NEO-122: Address follow-up review doc drift and QA numbering.
Reconcile plan with economy HUD layout refactor, fix manual QA steps,
update regression notes for NEO-74/75/110, and strike through resolved
review suggestions.
2026-06-07 13:36:21 -04:00
VinPropane 64da7611d9 NEO-122: Add follow-up code review for economy HUD layout. 2026-06-07 13:35:06 -04:00
VinPropane 632a7472de NEO-122: Fix HudRootScroll node path type for get_node_or_null. 2026-06-07 13:08:02 -04:00
VinPropane 0b3ef92ab6 NEO-122: Separate debug HUD scroll from economy panel.
Clamp HudRootScroll above the economy block, add an opaque panel fill,
and shrink the economy max height so quest and recipe text no longer overlap.
2026-06-07 13:07:58 -04:00
VinPropane f801692091 NEO-122: Fix economy HUD positioning so panel renders on screen.
Bottom anchors placed the section off-screen before layout; use explicit
position/size from viewport height and draw above HudRoot via z_index.
2026-06-07 13:00:56 -04:00
VinPropane d1fa08b8c4 NEO-122: Pin economy HUD to viewport so recipes stay visible.
Reparent EconomyHudSection out of HudRoot, clamp its height to the
viewport, put recipes first in a single BodyScroll, and update tests.
2026-06-07 12:58:31 -04:00
VinPropane e041c02959 NEO-122: Fix craft recipe panel scroll and row clipping.
Stack recipe label above Craft button so rows fit panel width, disable
horizontal scroll, reset scroll to top on populate, and bump panel height.
2026-06-07 12:54:57 -04:00
VinPropane b36fe6d863 NEO-122: Address code review HUD error and test gaps.
Surface quest-definitions sync failures on QuestProgressLabel, add accept
failure and busy-guard GdUnit coverage, inline render helper, and reconcile
review doc with strikethroughs.
2026-06-07 12:45:47 -04:00
VinPropane 4e75ec6493 NEO-122: Add code review for quest progress HUD. 2026-06-07 12:44:35 -04:00
VinPropane e37671d815 NEO-122: Add Godot quest progress and accept HUD.
Wire quest-progress GET and accept POST clients with four-quest HUD labels,
Q/Shift+Q accept bindings, and event-driven refresh after gather, craft, and
NPC defeats; includes GdUnit coverage and manual QA checklist.
2026-06-07 12:43:27 -04:00
VinPropane 150cb73f9a NEO-122: Add kickoff implementation plan for quest progress HUD. 2026-06-07 12:40:40 -04:00
VinPropane a3932752b8
Merge pull request #160 from ViPro-Technologies/NEO-121-quest-telemetry-hooks
NEO-121: Quest telemetry hook sites (comment-only)
2026-06-07 12:37:21 -04:00
VinPropane 88d01778a1 NEO-121: Address review nits for quest telemetry hook sites.
Add schemaVersion to quest_start comment, thread playerId/questId into
DenyAccept, and merge duplicate deny snapshot helpers.
2026-06-07 12:33:34 -04:00
VinPropane 89affe2b32 NEO-121: Add code review for quest telemetry hook sites. 2026-06-07 12:32:35 -04:00
VinPropane 705a7cd028 NEO-121: Add comment-only quest telemetry hook sites in QuestStateOperations.
Document quest_start, quest_step_complete, quest_complete, and quest_accept_denied
anchors for future E9.M1 ingest with README, manual QA, and module doc updates.
2026-06-07 12:29:52 -04:00
VinPropane 72b98a627c NEO-121: Add kickoff implementation plan for quest telemetry hooks. 2026-06-07 12:28:11 -04:00
VinPropane 6af9f42df8
Merge pull request #159 from ViPro-Technologies/NEO-120-quest-accept-post
NEO-120: POST /game/players/{id}/quests/{questId}/accept
2026-06-07 12:25:50 -04:00
VinPropane ea3b7cceb5 NEO-120: Address code review test coverage gaps.
Add HTTP tests for already_completed deny snapshot and empty {} body;
tighten already_active AAA arrange. Strike through resolved review items.
2026-06-07 12:21:19 -04:00
VinPropane 14bdec367c NEO-120: Add code review for quest accept POST branch. 2026-06-07 12:19:56 -04:00
VinPropane 9fe6cb36fd NEO-120: Document quest accept POST and reconcile plan.
Update server README, E7.M1 module docs, alignment register, backlog, and
implementation plan reconciliation for shipped accept HTTP.
2026-06-07 12:18:53 -04:00
VinPropane 2fa2e35ca6 NEO-120: Add quest accept POST API, tests, and Bruno spine.
Wire QuestStateOperations.TryAccept at POST /game/players/{id}/quests/{questId}/accept
with shared quest row projection, integration tests, and Bruno accept flows.
2026-06-07 12:18:51 -04:00
VinPropane b22b68ad1f NEO-120: Add implementation plan for quest accept POST.
Kickoff plan for E7M1-09 — wire QuestStateOperations.TryAccept over HTTP
with Bruno and integration tests; client counterpart NEO-122.
2026-06-07 12:16:57 -04:00
VinPropane c388949b8d
Merge pull request #158 from ViPro-Technologies/NEO-119-get-quest-progress
NEO-119: GET /game/players/{id}/quest-progress
2026-06-07 12:15:00 -04:00
VinPropane ad906fec40 NEO-119: Tighten quest-progress order and side-effect test coverage. 2026-06-07 12:10:55 -04:00
VinPropane 1565d2cde7 NEO-119: Add code review for quest-progress GET branch. 2026-06-07 12:09:57 -04:00
VinPropane 9e4c52f653 NEO-119: Document quest-progress GET and reconcile plan docs. 2026-06-07 11:56:31 -04:00
VinPropane 066d1ff981 NEO-119: Add GET quest-progress API with inventory refresh hook. 2026-06-07 11:56:28 -04:00
VinPropane 78e5de3338 NEO-119: Add kickoff implementation plan for quest progress GET. 2026-06-07 11:50:11 -04:00
VinPropane cbd6706e41
Merge pull request #157 from ViPro-Technologies/NEO-118-quest-objective-wiring
NEO-118: Wire quest objectives to gather, craft, and encounter events
2026-06-07 11:28:14 -04:00
VinPropane 71dbdd9a3e NEO-118: Wire quest objectives to gather, craft, and encounter events.
Add QuestObjectiveWiring with auto step advance/complete, hook into
GatherOperations, CraftOperations, EncounterCompletionOperations, and
QuestStateOperations accept/advance for inventory_has_item checks.
2026-06-06 19:54:37 -04:00
VinPropane 286a1a036f NEO-118: Add kickoff implementation plan for quest objective wiring. 2026-06-06 19:48:27 -04:00
121 changed files with 9581 additions and 283 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
uid://bneo122qstdefs01

View File

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

View File

@ -0,0 +1 @@
uid://c8k3m2questhudctrl01

View File

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

View File

@ -0,0 +1 @@
uid://bneo122qstprog01

View File

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

View File

@ -0,0 +1 @@
uid://bneo122craftpanel1

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
uid://bneo122qstdeft01

View File

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

View File

@ -0,0 +1 @@
uid://c8k3m2questhudtest01

View File

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

View File

@ -0,0 +1 @@
uid://bneo122qsttest01

View File

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

View File

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

View File

@ -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."
}
}
}

View File

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

View File

@ -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."
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 19 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 78 (recipes first row, no overlap with quest labels).

View File

@ -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-112NEO-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 119 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.

View File

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

View File

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

View File

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

View File

@ -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 **19** 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-124NEO-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)

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (postfirst 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** |

View File

@ -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-112NEO-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-112NEO-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 14 (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 plans **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** |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 159162). 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 35) 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 12 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.

View File

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

View File

@ -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 **12** addressed in `b36fe6d`; suggestion **2** (extract quest HUD) still deferred.
**Follow-up:** suggestions **12** 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 18” while required steps now include **9**).~~ **Done.** Steps 112; acceptance covers steps 19 + 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 19 (craft step 78: **Refine Scrap (Standard)** first row, scroll at top).
3. Optional error paths (steps 1011).
4. Toggle **Economy HUD** off/on — debug labels in **`HudRootScroll`** remain; recipe repopulate + scroll reset still works.

View File

@ -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 **12** 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"
```

View File

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

View File

@ -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; Pythons 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.

View File

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

View File

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

View File

@ -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), "

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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