Compare commits

...

82 Commits

Author SHA1 Message Date
VinPropane d236aee261
Merge pull request #152 from ViPro-Technologies/NEO-113-e7m1-server-quest-catalog-load-fail-fast
NEO-113: E7M1-02 server quest catalog load (fail-fast)
2026-05-31 22:44:50 -04:00
VinPropane 9c2ab5ae4d NEO-113: address code review — docs, schema and chain terminal tests. 2026-05-31 22:36:26 -04:00
VinPropane 38310d6ed3 NEO-113: add code review for quest catalog load branch. 2026-05-31 22:35:26 -04:00
VinPropane 1044547cc5 NEO-113: document quest catalog load and reconcile plan. 2026-05-31 22:33:43 -04:00
VinPropane b841b64581 NEO-113: add fail-fast quest catalog load and loader tests. 2026-05-31 22:33:43 -04:00
VinPropane cb4761b0b4 NEO-113: add kickoff implementation plan for quest catalog load. 2026-05-31 21:31:14 -04:00
VinPropane df24281417
Merge pull request #151 from ViPro-Technologies/NEO-112-e7m1-prototype-questdef-catalog-schemas-ci
NEO-112: E7M1-01 prototype QuestDef catalog + schemas + CI
2026-05-31 21:28:54 -04:00
VinPropane a4ed4abadb NEO-112: enforce objective id uniqueness per quest in CI
Move obj_ids_seen to quest scope so duplicate objective ids are
rejected across steps, matching CT.M1 documentation and E7M1-04
HTTP projection expectations (Bugbot PR #151).
2026-05-31 21:23:45 -04:00
VinPropane 50956bac11 NEO-112: address code review doc alignment findings 2026-05-31 21:17:37 -04:00
VinPropane c55c373f7f NEO-112: add code review for QuestDef catalog + CI gates 2026-05-31 21:16:56 -04:00
VinPropane 765075f3cf NEO-112: document quest catalog CI and reconcile implementation plan 2026-05-31 21:16:12 -04:00
VinPropane 81ef9b94cb NEO-112: extend validate_content.py with E7M1 quest CI gates 2026-05-31 21:16:12 -04:00
VinPropane 91a4abfe86 NEO-112: add QuestDef schemas and prototype quest catalog 2026-05-31 21:16:10 -04:00
VinPropane 140d857398 NEO-112: add E7M1 prototype backlog and decomposition cross-links 2026-05-31 21:13:03 -04:00
VinPropane be1dae3872 NEO-112: add kickoff implementation plan for QuestDef catalog + CI 2026-05-31 21:12:54 -04:00
VinPropane 47105ccd90
Merge pull request #150 from ViPro-Technologies/NEO-111-playable-encounter-clear-capstone
NEO-111: Playable encounter clear capstone (Godot)
2026-05-31 20:15:57 -04:00
VinPropane 5e4816eddd NEO-111: record manual QA pass; mark E5.M3 Ready.
Capstone checklist passed in Godot; Epic 5 Slice 3 client complete.
2026-05-31 20:11:15 -04:00
VinPropane 2e54fe39f8 NEO-111: reconcile plan and module docs for encounter capstone.
Document shipped capstone artifacts; manual Godot QA pass still pending
before E5.M3 Ready and backlog AC checkboxes.
2026-05-31 20:02:14 -04:00
VinPropane e900ba992b NEO-111: add capstone manual QA script and README loop section.
Single-session Godot checklist with flexible defeat order, inventory
verification, and Godot-restart idempotency; cross-link from NEO-110.
2026-05-31 20:02:14 -04:00
VinPropane 385cabe883 NEO-111: kickoff implementation plan for encounter clear capstone.
Docs-primary Slice 3 capstone: manual QA script, README loop section,
and module alignment; integration fixes only if Godot QA fails.
2026-05-31 20:01:11 -04:00
VinPropane 82c966d7d2 NEO-110: record manual QA pass on plan; add test script uid. 2026-05-31 19:57:13 -04:00
VinPropane e87064f43f
Merge pull request #149 from ViPro-Technologies/NEO-110-client-encounter-progress-loot-feedback-hud
NEO-110: Client encounter progress + loot feedback HUD
2026-05-31 19:51:31 -04:00
VinPropane 2bde771107 chore: pre-push hook bash 3.2 — no local outside functions. 2026-05-31 19:46:51 -04:00
VinPropane cfad03e61f chore: document git hooks and gdtoolkit setup in repo README. 2026-05-31 19:45:22 -04:00
VinPropane e93fb22f1a chore: bash 3.2-safe pre-commit; document hooksPath + gdtoolkit setup. 2026-05-31 19:43:53 -04:00
VinPropane 4379e42106 NEO-110: fix gdlint line length and gdformat for CI. 2026-05-31 19:43:49 -04:00
VinPropane 24d598e0a2 chore: run gdlint/gdformat on pre-push only when client .gd changes. 2026-05-31 19:42:23 -04:00
VinPropane c8d475670c NEO-110: address review — loot label helper, defs re-paint, tests. 2026-05-31 19:38:07 -04:00
VinPropane 3ce645a0f0 NEO-110: code review — request changes for loot label helper. 2026-05-31 19:37:21 -04:00
VinPropane bcc9bf1c69 NEO-110: manual QA, README, plan reconciliation, and module docs. 2026-05-31 19:35:56 -04:00
VinPropane d81d19b859 NEO-110: encounter progress client, HUD labels, and GdUnit tests. 2026-05-31 19:35:54 -04:00
VinPropane 3128f36df9 NEO-110: kickoff implementation plan for encounter HUD client. 2026-05-31 19:34:27 -04:00
VinPropane c3022201fe
Merge pull request #148 from ViPro-Technologies/NEO-109-slice-3-encounter-telemetry-hook-sites
NEO-109: Slice 3 encounter telemetry hook sites
2026-05-31 19:29:34 -04:00
VinPropane df248308c7 NEO-109: Restore early return on completion failure guard.
Bugbot PR #148: keep consistent guard-clause pattern in
EncounterCombatWiring.TryProcessCastOutcome.
2026-05-31 19:25:33 -04:00
VinPropane 0f60423863 NEO-109: Drop unused definition out param in activation path. 2026-05-31 19:06:06 -04:00
VinPropane c40bcd3ffe NEO-109: Address code review hook-anchor findings.
Move encounter_start comments after successful TryActivate; clarify
encounter_complete_denied caller context; strike through review items.
2026-05-31 19:06:01 -04:00
VinPropane d1a67a89bd NEO-109: Add code review for encounter telemetry hook sites. 2026-05-31 19:04:55 -04:00
VinPropane 8cda0d76da NEO-109: Document encounter telemetry hooks in README and module docs.
Reconcile plan, backlog, alignment register, and E5.M3 module snapshot.
2026-05-31 19:03:03 -04:00
VinPropane 1a7b5312c5 NEO-109: Add Slice 3 encounter telemetry hook comments.
Document encounter_start, encounter_complete, reward_attribution, and
encounter_complete_denied at engine anchors; remove wiring duplicate.
2026-05-31 19:03:03 -04:00
VinPropane e6debc2968 NEO-109: Add kickoff implementation plan.
Documents comment-only encounter_start, encounter_complete, and
reward_attribution hook sites with kickoff decisions adopted.
2026-05-31 19:01:28 -04:00
VinPropane 4aa7665e7c
Merge pull request #147 from ViPro-Technologies/NEO-108-get-encounter-progress-wire-dtos-bruno
NEO-108: GET encounter-progress + wire DTOs + Bruno
2026-05-31 18:58:59 -04:00
VinPropane 40d948ff2c NEO-108: Bruno elite defeat uses 12 pulses; reset HP baseline.
Elite has 200 HP (8 pulses at 25 damage); defeat loop matches C# tests.
Combat-health seq 1 resets dev fixture so currentHp is 100 after prior smokes.
2026-05-31 18:50:29 -04:00
VinPropane 05beec9644 NEO-108: Use bru.sleep in Bruno helper (no setTimeout).
Bruno pre-request sandbox does not define setTimeout; pulse waits and
telegraph polls now call bru.sleep via prototype-npc-bruno-helper.
2026-05-31 18:48:22 -04:00
VinPropane af80b878ad NEO-108: Bruno move near each NPC and isolate pulse slot.
Ranged/elite casts failed out_of_range after melee at (-5,-5); add
prototype-npc-bruno-helper moves and pulse cooldown between chains.
Combat-health uses slot 4 and moves near elite before cast.
2026-05-31 18:46:00 -04:00
VinPropane a5182a4c14 NEO-108: Fix Bruno encounter-progress and combat-health smokes.
Use collection-relative ./scripts require path; reset encounter stores in
combat-targets dev fixture for inactive GET; combat-health uses slot 3.
2026-05-31 18:43:50 -04:00
VinPropane 25f3714554 NEO-108: Address code review findings for encounter-progress GET.
Add BuildSnapshot invariant tests, whitespace 404, idempotent second GET,
grant-order assertions; dedupe MapRow progress lookup; strike review items.
2026-05-31 18:39:52 -04:00
VinPropane ebb27aed2d NEO-108: Add code review for encounter-progress GET. 2026-05-31 18:38:31 -04:00
VinPropane 07ebb9efb8 NEO-108: Add Bruno smokes and document encounter-progress GET.
Bruno folder covers inactive default and three-NPC defeat spine; README and
E5.M3 docs mark E5M3-08 landed with plan reconciliation.
2026-05-31 18:37:43 -04:00
VinPropane c1fd2053d0 NEO-108: Add GET encounter-progress API and integration tests.
Projects progress, completion, and event stores into versioned per-player
rows with inactive/active/completed state and commit-time grant summary.
2026-05-31 18:37:42 -04:00
VinPropane 16018586b7 NEO-108: Add kickoff implementation plan for encounter-progress GET.
Documents HTTP projection from progress, completion, and event stores,
Bruno defeat spine, and adopted grant-summary source from kickoff.
2026-05-31 18:35:42 -04:00
VinPropane 51e2007d60
Merge pull request #146 from ViPro-Technologies/NEO-107-encounter-complete-event-e7m2-hook
NEO-107: EncounterCompleteEvent persistence + E7.M2 hook stub
2026-05-31 18:34:16 -04:00
VinPropane f51617f8b5 NEO-107: Address code review findings for event store persistence.
Update E5.M3 module summary and NEO-105 snapshot, fix store test AAA
layout, add empty encounter id fail-closed test, strike through review items.
2026-05-31 18:27:28 -04:00
VinPropane 29217c0fa0 NEO-107: Add code review for EncounterCompleteEvent persistence. 2026-05-31 18:26:51 -04:00
VinPropane 555c3235ff NEO-107: Reconcile plan and module docs for event persistence.
Mark E5M3-09 acceptance criteria complete and cross-link E7.M2 consumer
hook anchor across backlog, alignment register, and module snapshots.
2026-05-31 18:26:13 -04:00
VinPropane a02496c6bc NEO-107: Persist EncounterCompleteEvent and wire E7.M2 hook stub.
Add IEncounterCompleteEventStore with in-memory implementation, record
events on successful TryCompleteAndGrant, and expand reward_delivery
hook comments for future QuestRewardBundle routing.
2026-05-31 18:26:11 -04:00
VinPropane 986cf55e73 NEO-107: Add kickoff implementation plan for EncounterCompleteEvent persistence. 2026-05-31 18:22:57 -04:00
VinPropane 856dc8dcf0
Merge pull request #145 from ViPro-Technologies/NEO-106-wire-combat-defeat-encounter-progress-completion
NEO-106: Wire combat defeat → encounter progress/completion
2026-05-31 18:16:21 -04:00
VinPropane 9c74933e6e NEO-106: Address code review findings for combat encounter wiring. 2026-05-31 18:10:41 -04:00
VinPropane a7b835290c NEO-106: Add code review for combat encounter wiring. 2026-05-31 18:09:20 -04:00
VinPropane 8306f92554 NEO-106: Document combat encounter wiring and update E5.M3 docs. 2026-05-31 18:08:29 -04:00
VinPropane f581cf412c NEO-106: Wire encounter progress and completion from ability cast. 2026-05-31 18:08:28 -04:00
VinPropane fc09808482 NEO-106: Add kickoff implementation plan for combat encounter wiring. 2026-05-31 18:05:42 -04:00
VinPropane 7656fded03
Merge pull request #144 from ViPro-Technologies/NEO-105-encounter-completion-inventory-reward-apply
NEO-105: EncounterCompletionOperations + inventory reward apply
2026-05-31 17:19:37 -04:00
VinPropane 01bcd7334c NEO-105: Consolidate inventory snapshot clone helper.
Add PlayerInventorySnapshot.Clone() and use it from craft and
encounter completion pre-flight paths (Bugbot PR #144).
2026-05-31 17:11:55 -04:00
VinPropane cbb6f137fd NEO-105: Address code review findings for encounter completion.
Add apply-phase rollback test, E9.M1 deny hook comment, inventory
reason passthrough, and tighten idempotent replay assertions.
2026-05-31 17:02:32 -04:00
VinPropane 1a74fbda54 NEO-105: Add code review for encounter completion grants. 2026-05-31 16:59:46 -04:00
VinPropane 009fedf65e NEO-105: Document encounter completion grants and update E5.M3 docs. 2026-05-31 16:57:54 -04:00
VinPropane 0cf82b9c0a NEO-105: Add encounter completion operations and inventory grants.
TryCompleteAndGrant applies reward-table fixed grants with pre-flight
simulate, compensating rollback, and idempotent completion marking.
Returns EncounterCompleteEvent payload for NEO-107 persistence.
2026-05-31 16:57:53 -04:00
VinPropane b5306aa933 NEO-105: Add kickoff implementation plan for encounter completion grants. 2026-05-31 16:55:22 -04:00
VinPropane 719a14f110
Merge pull request #143 from ViPro-Technologies/NEO-104-encounter-progress-completion-stores
NEO-104: E5M3-05 encounter progress + completion stores
2026-05-31 16:51:40 -04:00
VinPropane 0289930897 NEO-104: Use subset check for all-targets-defeated predicate.
Matches documented semantics; extra defeated ids via direct store
calls no longer block completion readiness (Bugbot PR #143).
2026-05-31 16:44:48 -04:00
VinPropane 91f74b7d6e NEO-104: Address code review findings for encounter stores.
Normalize encounter ids in IsAllRequiredTargetsDefeated, document TryActivate
completion gating, lock IsCompleted reads, add operations tests, and update
plan and review doc.
2026-05-31 16:37:22 -04:00
VinPropane 87dff6e3f4 NEO-104: Add code review for encounter progress stores. 2026-05-31 16:36:42 -04:00
VinPropane aaccbbbcaf NEO-104: Add encounter progress and completion stores.
In-memory IEncounterProgressStore and IEncounterCompletionStore with
EncounterProgressOperations for activation, defeat tracking, and
all-targets-defeated checks; unit tests and docs.
2026-05-31 16:18:56 -04:00
VinPropane 727ce4774a NEO-104: Add kickoff implementation plan for encounter stores. 2026-05-31 16:16:03 -04:00
VinPropane aa81914e9f
Merge pull request #142 from ViPro-Technologies/NEO-103-get-world-encounter-definitions
NEO-103: GET /game/world/encounter-definitions + Bruno
2026-05-31 15:53:16 -04:00
VinPropane e4e75f5dc7 NEO-103: Address code review follow-ups.
Mark E5M3-04 landed in module Summary, assert fixedGrants order in
C# test, add Bruno ordinal comment, strike through review items.
2026-05-31 15:43:37 -04:00
VinPropane 573c42add7 NEO-103: Add code review for encounter-definitions GET. 2026-05-31 15:41:49 -04:00
VinPropane 935e8d5f16 NEO-103: Document encounter-definitions route and mark E5M3-04 landed.
Update server README, module snapshot, alignment register, backlog,
and implementation plan reconciliation.
2026-05-31 15:40:12 -04:00
VinPropane 14b623c67f NEO-103: Add Bruno smoke for encounter-definitions GET.
Assert frozen prototype_combat_pocket row and nested reward grants.
2026-05-31 15:40:12 -04:00
VinPropane 9b48ec5f39 NEO-103: Add GET /game/world/encounter-definitions API.
Expose schema v1 encounter list with nested completionCriteria and
rewardTable summary via encounter and reward-table registries.
2026-05-31 15:40:12 -04:00
VinPropane 62e6bfdf0a NEO-103: Add implementation plan for encounter-definitions GET.
Kickoff clarifications adopt nested rewardTable summary, nested
completionCriteria shape, and skip manual QA (server-only).
2026-05-31 15:38:53 -04:00
113 changed files with 9073 additions and 117 deletions

View File

@ -59,7 +59,7 @@ CI also enforces **`gdlint`** and **`gdformat`** (see `gdlintrc` and `.github/wo
`gdformat --check client/scripts client/test` `gdformat --check client/scripts client/test`
- If formatting fails, run: - If formatting fails, run:
`gdformat client/scripts client/test` `gdformat client/scripts client/test`
- Install the repos local **pre-push** hook from the repo root to enforce those checks automatically: - Install the repos local **pre-push** hook from the repo root to enforce those checks when **`client/scripts/`** or **`client/test/`** `.gd` files change in the push:
`./scripts/install-git-hooks.sh` `./scripts/install-git-hooks.sh`
## GdUnit test layout (AAA) ## GdUnit test layout (AAA)

View File

@ -52,3 +52,19 @@ Connection (dev): host `localhost`, port `5432`, database `neon_sprawl`, user `n
### Run the client ### Run the client
Open the [`client/`](client/) folder in **Godot 4.6** and run the main scene (see [`client/README.md`](client/README.md)). Open the [`client/`](client/) folder in **Godot 4.6** and run the main scene (see [`client/README.md`](client/README.md)).
### Git hooks and GDScript lint (recommended)
Once per clone, from the repo root:
```bash
./scripts/install-git-hooks.sh
python3 -m venv .venv-gd
.venv-gd/bin/pip install "gdtoolkit==4.5.0"
```
This sets **`core.hooksPath=scripts/git-hooks`**. On push, when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, **pre-push** runs **`gdlint`** and **`gdformat --check`** on those trees (same as [`.github/workflows/gdscript.yml`](.github/workflows/gdscript.yml)). Install **gdtoolkit** in **`.venv-gd`** (gitignored) or globally so the hook can run; otherwise pushes with GDScript changes are blocked.
Windows (PowerShell): `pwsh -File scripts/install-git-hooks.ps1`, then create `.venv-gd` and `pip install "gdtoolkit==4.5.0"` the same way.
More detail: [`client/README.md`](client/README.md) (Git hooks section).

View File

@ -13,19 +13,21 @@ docs {
script:pre-request { script:pre-request {
const axios = require("axios"); const axios = require("axios");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js"); const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
const {
moveNearPrototypeNpc,
sleep,
} = require("./scripts/prototype-npc-bruno-helper.js");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } }; const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const slotIndex = 4;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
await resetPrototypeCombatTargets(bru); await resetPrototypeCombatTargets(bru);
await axios.post( await moveNearPrototypeNpc(
`${baseUrl}/game/players/${playerId}/move`, baseUrl,
{ schemaVersion: 1, target: { x: -2, y: 0.9, z: -2 } }, playerId,
"prototype_npc_elite",
jsonHeaders, jsonHeaders,
); );
@ -33,7 +35,7 @@ script:pre-request {
`${baseUrl}/game/players/${playerId}/hotbar-loadout`, `${baseUrl}/game/players/${playerId}/hotbar-loadout`,
{ {
schemaVersion: 1, schemaVersion: 1,
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }], slots: [{ slotIndex, abilityId: "prototype_pulse" }],
}, },
jsonHeaders, jsonHeaders,
); );
@ -48,7 +50,7 @@ script:pre-request {
`${baseUrl}/game/players/${playerId}/ability-cast`, `${baseUrl}/game/players/${playerId}/ability-cast`,
{ {
schemaVersion: 1, schemaVersion: 1,
slotIndex: 0, slotIndex,
abilityId: "prototype_pulse", abilityId: "prototype_pulse",
targetId: "prototype_npc_elite", targetId: "prototype_npc_elite",
}, },
@ -62,10 +64,10 @@ script:pre-request {
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
await sleep(5000); await sleep(bru, 5000);
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
await sleep(2500); await sleep(bru, 2500);
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`); await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
bru.setVar("neo95CastBody", JSON.stringify(castBody)); bru.setVar("neo95CastBody", JSON.stringify(castBody));

View File

@ -4,6 +4,11 @@ meta {
seq: 1 seq: 1
} }
script:pre-request {
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
await resetPrototypeCombatTargets(bru);
}
get { get {
url: {{baseUrl}}/game/players/dev-local-1/combat-health url: {{baseUrl}}/game/players/dev-local-1/combat-health
body: none body: none

View File

@ -0,0 +1,55 @@
meta {
name: GET encounter definitions
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/world/encounter-definitions
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and encounters array", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.encounters).to.be.an("array");
expect(body.encounters.length).to.equal(1);
});
test("encounters are ascending by id (ordinal)", function () {
const body = res.getBody();
const ids = body.encounters.map((x) => x.id);
// Match server StringComparer.Ordinal (prototype ids are ASCII snake_case).
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
expect(ids).to.eql(sorted);
});
test("prototype_combat_pocket row matches catalog", function () {
const body = res.getBody();
const row = body.encounters.find((x) => x.id === "prototype_combat_pocket");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Prototype Combat Pocket");
expect(row.completionCriteria).to.eql({ kind: "defeat_all_targets" });
expect(row.requiredNpcInstanceIds).to.eql([
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite",
]);
});
test("nested rewardTable matches frozen grants", function () {
const body = res.getBody();
const row = body.encounters.find((x) => x.id === "prototype_combat_pocket");
expect(row.rewardTable).to.be.an("object");
expect(row.rewardTable.id).to.equal("prototype_combat_pocket_clear");
expect(row.rewardTable.displayName).to.equal("Prototype Combat Pocket Clear");
expect(row.rewardTable.fixedGrants).to.eql([
{ itemId: "scrap_metal_bulk", quantity: 10 },
{ itemId: "contract_handoff_token", quantity: 1 },
]);
});
}

View File

@ -0,0 +1,3 @@
meta {
name: encounter-definitions
}

View File

@ -0,0 +1,153 @@
meta {
name: GET encounter progress after defeat spine
type: http
seq: 2
}
docs {
NEO-108 AC: three-NPC defeat chain via cast (slot 3), then GET shows completed once with frozen grant summary.
}
script:pre-request {
const axios = require("axios");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
const {
moveNearPrototypeNpc,
PULSE_COOLDOWN_MS,
MAX_PULSE_DEFEAT_ATTEMPTS,
sleep,
} = require("./scripts/prototype-npc-bruno-helper.js");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const slotIndex = 3;
function rowForPocket(body) {
const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket");
if (!row) {
throw new Error(`missing prototype_combat_pocket row: ${JSON.stringify(body)}`);
}
return row;
}
async function getProgress() {
const response = await axios.get(
`${baseUrl}/game/players/${playerId}/encounter-progress`,
jsonHeaders,
);
if (response.status !== 200) {
throw new Error(`encounter-progress GET failed: ${response.status}`);
}
return response.data;
}
async function defeatNpc(targetId) {
await moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders);
await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId },
jsonHeaders,
);
await sleep(bru, PULSE_COOLDOWN_MS);
const castBody = {
schemaVersion: 1,
slotIndex,
abilityId: "prototype_pulse",
targetId,
};
for (let i = 0; i < MAX_PULSE_DEFEAT_ATTEMPTS; i++) {
const response = await axios.post(
`${baseUrl}/game/players/${playerId}/ability-cast`,
castBody,
jsonHeaders,
);
const body = response.data;
if (!body?.accepted || !body?.combatResolution) {
throw new Error(
`cast ${i + 1} vs ${targetId} failed (${body?.reasonCode ?? "no_reason"}): ${JSON.stringify(body)}`,
);
}
if (body.combatResolution.targetDefeated) {
return;
}
if (i < MAX_PULSE_DEFEAT_ATTEMPTS - 1) {
await sleep(bru, PULSE_COOLDOWN_MS);
}
}
throw new Error(
`did not defeat ${targetId} within ${MAX_PULSE_DEFEAT_ATTEMPTS} pulses`,
);
}
await resetPrototypeCombatTargets(bru);
await axios.post(
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
{
schemaVersion: 1,
slots: [{ slotIndex, abilityId: "prototype_pulse" }],
},
jsonHeaders,
);
await defeatNpc("prototype_npc_melee");
const afterOne = await getProgress();
const rowOne = rowForPocket(afterOne);
if (rowOne.state !== "active" || rowOne.defeatedTargetIds.length !== 1) {
throw new Error(`after melee: expected active with 1 defeat, got ${JSON.stringify(rowOne)}`);
}
await defeatNpc("prototype_npc_ranged");
const afterTwo = await getProgress();
const rowTwo = rowForPocket(afterTwo);
if (rowTwo.state !== "active" || rowTwo.defeatedTargetIds.length !== 2) {
throw new Error(`after ranged: expected active with 2 defeats, got ${JSON.stringify(rowTwo)}`);
}
await defeatNpc("prototype_npc_elite");
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/encounter-progress
body: none
auth: none
}
script:post-response {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const second = await axios.get(`${baseUrl}/game/players/${playerId}/encounter-progress`);
bru.setVar("neo108SecondGetJson", JSON.stringify(second.data));
}
tests {
test("completed row shows three defeats and grant summary", function () {
const body = res.getBody();
expect(res.getStatus()).to.equal(200);
expect(body.schemaVersion).to.equal(1);
const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket");
expect(row).to.be.an("object");
expect(row.state).to.equal("completed");
expect(row.defeatedTargetIds).to.eql([
"prototype_npc_elite",
"prototype_npc_melee",
"prototype_npc_ranged",
]);
expect(row.completedAt).to.be.a("string");
expect(row.rewardGrantSummary).to.eql([
{ itemId: "scrap_metal_bulk", quantity: 10 },
{ itemId: "contract_handoff_token", quantity: 1 },
]);
});
test("second GET still completed (idempotent read)", function () {
const second = JSON.parse(bru.getVar("neo108SecondGetJson"));
const row = second.encounters.find((x) => x.encounterId === "prototype_combat_pocket");
expect(row.state).to.equal("completed");
expect(row.rewardGrantSummary).to.eql([
{ itemId: "scrap_metal_bulk", quantity: 10 },
{ itemId: "contract_handoff_token", quantity: 1 },
]);
});
}

View File

@ -0,0 +1,42 @@
meta {
name: GET encounter progress inactive
type: http
seq: 1
}
docs {
NEO-108: fresh player row before engagement — inactive with empty defeatedTargetIds.
Pre-request uses combat-targets dev fixture (also clears encounter progress for dev-local-1).
}
script:pre-request {
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
await resetPrototypeCombatTargets(bru);
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/encounter-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.encounters).to.be.an("array");
expect(body.encounters.length).to.equal(1);
});
test("prototype_combat_pocket is inactive with no defeats", function () {
const body = res.getBody();
const row = body.encounters.find((x) => x.encounterId === "prototype_combat_pocket");
expect(row).to.be.an("object");
expect(row.state).to.equal("inactive");
expect(row.defeatedTargetIds).to.eql([]);
expect(row.completedAt).to.equal(undefined);
expect(row.rewardGrantSummary).to.equal(undefined);
});
}

View File

@ -0,0 +1,3 @@
meta {
name: encounter-progress
}

View File

@ -0,0 +1,25 @@
meta {
name: GET health (quest catalog boot NEO-113)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-113 loads content/quests/*_quests.json at startup (fail-fast). No quest HTTP API in this story — use this request to confirm the host started after catalog validation.
}
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

@ -0,0 +1,3 @@
meta {
name: quest-catalog
}

View File

@ -0,0 +1,43 @@
const axios = require("axios");
/** Matches server <see cref="PrototypeNpcRegistry"/> anchors + AbilityCastApiTests move offset. */
const PROTOTYPE_NPC_ANCHORS = {
prototype_npc_melee: { x: -3, y: 0.5, z: -3 },
prototype_npc_ranged: { x: 3, y: 0, z: 3 },
prototype_npc_elite: { x: 0, y: 0.5, z: 0 },
};
/** <c>prototype_pulse</c> catalog cooldown (seconds) — real-time Bruno waits. */
const PULSE_COOLDOWN_MS = 3200;
/** Matches <c>AbilityCastApiTests</c> defeat loop (elite 200 HP needs 8×25 damage). */
const MAX_PULSE_DEFEAT_ATTEMPTS = 12;
/** Bruno sandbox has no <c>setTimeout</c> — use <c>bru.sleep</c>. */
async function sleep(bru, ms) {
await bru.sleep(ms);
}
async function moveNearPrototypeNpc(baseUrl, playerId, targetId, jsonHeaders) {
const anchor = PROTOTYPE_NPC_ANCHORS[targetId];
if (!anchor) {
throw new Error(`unknown prototype npc: ${targetId}`);
}
await axios.post(
`${baseUrl}/game/players/${playerId}/move`,
{
schemaVersion: 1,
target: { x: anchor.x - 1, y: 0.9, z: anchor.z - 1 },
},
jsonHeaders,
);
}
module.exports = {
PROTOTYPE_NPC_ANCHORS,
PULSE_COOLDOWN_MS,
MAX_PULSE_DEFEAT_ATTEMPTS,
moveNearPrototypeNpc,
sleep,
};

View File

@ -213,6 +213,41 @@ Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility
Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.md). Full capstone checklist: [`docs/manual-qa/NEO-86.md`](../docs/manual-qa/NEO-86.md).
## Encounter progress + loot HUD (NEO-110)
- **`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`**).
- **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`**.
Full checklist: [`docs/manual-qa/NEO-110.md`](../docs/manual-qa/NEO-110.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.
**Flow:** fresh server restart → boot encounter HUD **0/3** → defeat ×3 (any order) → progress **1/3 → 2/3 → completed** → loot label + inventory → Godot restart idempotency.
| Step | Input / trigger | HUD / server outcome |
|------|-----------------|----------------------|
| Boot | Godot **F5** + fresh server | **`EncounterProgressLabel`** **`not started (0/3)`**; **`Loot: —`**; empty bag |
| Engage | **Tab** + **1** on first NPC | Encounter activates on first damaging hit (NEO-106); progress **`1/3`** after defeat |
| Second defeat | Lock + cast second NPC | **`2/3`**; loot still **`—`** |
| Third defeat | Lock + cast third NPC | **`completed (3/3)`**; **`EncounterCompleteLabel`** lists grants; **`InventoryLabel`** auto-refreshes |
| Idempotency | Stop Godot + **F5** (server still running) | Encounter still **`completed`**; inventory counts unchanged |
| Re-cast defeated | **Tab** + **1** on defeated NPC | **`ability_cast_denied: target_defeated`**; no duplicate loot or gig XP |
**Cross-links:** [NEO-106](../docs/plans/NEO-106-implementation-plan.md) combat wiring · [NEO-108](../docs/plans/NEO-108-implementation-plan.md) encounter-progress GET · [NEO-110](../docs/manual-qa/NEO-110.md) HUD components · [NEO-98](../docs/manual-qa/NEO-98.md) NPC combat · [NEO-86](../docs/manual-qa/NEO-86.md) gig XP per defeat.
**Scripts:** `encounter_progress_client.gd`, `inventory_client.gd`, `ability_cast_client.gd`, `combat_targets_client.gd` — wired from `main.gd`.
**Preconditions:** **Server restart** before capstone run resets NPC HP, encounter progress, inventory, and gig XP.
Full capstone checklist: [`docs/manual-qa/NEO-111.md`](../docs/manual-qa/NEO-111.md).
## Inventory snapshot HUD (NEO-72) ## Inventory snapshot HUD (NEO-72)
- **`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/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).
@ -326,24 +361,31 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
**Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor. **Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor.
**Git hooks (recommended):** install the repos local hooks from the repo root (once per clone): **Git hooks (recommended):** configure hooks from the repo root (once per clone). This sets **`core.hooksPath=scripts/git-hooks`** so Git runs the tracked **`pre-commit`** / **`pre-push`** scripts (bash 3.2+ compatible):
```bash ```bash
./scripts/install-git-hooks.sh ./scripts/install-git-hooks.sh
``` ```
On **Windows** (PowerShell), same hook: On **Windows** (PowerShell):
```powershell ```powershell
pwsh -File scripts/install-git-hooks.ps1 pwsh -File scripts/install-git-hooks.ps1
``` ```
This installs: Then install **gdtoolkit** (same version as CI) when you work on GDScript:
```bash
python3 -m venv .venv-gd
.venv-gd/bin/pip install "gdtoolkit==4.5.0"
```
This enables:
- **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, or when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates. - **pre-commit**: blocks commits when `client/scripts/*.gd` changes lack matching `client/test/*_test.gd` updates, or when server route/contract files change without corresponding `server/NeonSprawl.Server.Tests/` and `bruno/neon-sprawl-server/**/*.bru` updates.
- **pre-push**: runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`**. - **pre-push**: when **`client/scripts/*.gd`** or **`client/test/*.gd`** changed in the commits being pushed, runs **`gdlint client/scripts client/test`** and **`gdformat --check client/scripts client/test`** (same scope as CI). Otherwise skips lint/format. Still requires a **clean working tree** on every push.
The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** and blocks the push — same as CI. The pre-push hook prefers **`.venv-gd/bin/`** (Unix) or **`.venv-gd/Scripts/`** (Windows venv) when present; without that venv or a global install, the hook **fails** when GDScript changed — same as CI. Pushes with no client `.gd` changes do not require gdtoolkit installed.
**If CI fails gdlint but your push succeeded:** the hook was not installed, **`gdlint`/`gdformat` were missing** when the hook ran, or push bypassed hooks (`--no-verify`). **If CI fails gdlint but your push succeeded:** the hook was not installed, **`gdlint`/`gdformat` were missing** when the hook ran, or push bypassed hooks (`--no-verify`).

View File

@ -20,6 +20,7 @@
[ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"] [ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"]
[ext_resource type="Script" path="res://scripts/prototype_economy_hud_section.gd" id="20_economy_hud"] [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/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="PackedScene" path="res://assets/district/prototype_district_environment.glb" id="22_district_env"] [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"] [ext_resource type="Script" path="res://scripts/prototype_district_art.gd" id="23_district_art"]
@ -1114,6 +1115,9 @@ script = ExtResource("16_skill_prog")
[node name="GigProgressionClient" type="Node" parent="." unique_id=2500011] [node name="GigProgressionClient" type="Node" parent="." unique_id=2500011]
script = ExtResource("21_gig_prog") script = ExtResource("21_gig_prog")
[node name="EncounterProgressClient" type="Node" parent="." unique_id=2500012]
script = ExtResource("24_enc_prog")
[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] [node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009]
script = ExtResource("17_recipe_defs") script = ExtResource("17_recipe_defs")
@ -1199,6 +1203,27 @@ theme_override_font_sizes/font_size = 22
autowrap_mode = 3 autowrap_mode = 3
text = "Player HP: —" text = "Player HP: —"
[node name="EncounterProgressLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000023]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.82, 0.72, 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 = "Encounter:
Loading…"
[node name="EncounterCompleteLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000024]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.95, 0.9, 0.65, 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 = "Loot: —"
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020] [node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3

View File

@ -0,0 +1,105 @@
extends Node
## NEO-110: HTTP client for per-player encounter-progress GET (NEO-108).
signal encounter_progress_received(snapshot: Dictionary)
signal encounter_sync_failed(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_http: Node = null
var _http: Node
var _busy: bool = false
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/players/%s/encounter-progress" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("EncounterProgressClient: %s" % reason)
_busy = false
encounter_sync_failed.emit(reason)
func encounter_row(encounter_id: String, snapshot: Dictionary = {}) -> Dictionary:
var encounters: Variant = snapshot.get("encounters", null)
if encounters == null or not encounters is Array:
return {}
for row_variant in encounters as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("encounterId", "")) == encounter_id:
return row
return {}
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
static func parse_encounter_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 encounters: Variant = root.get("encounters", null)
if encounters == null or not encounters is Array:
return null
if str(root.get("playerId", "")).strip_edges().is_empty():
return null
return root
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("EncounterProgressClient: %s" % reason)
encounter_sync_failed.emit(reason)
return
if response_code == 404:
var reason404 := "HTTP 404 (player unknown)"
push_warning("EncounterProgressClient: %s" % reason404)
encounter_sync_failed.emit(reason404)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("EncounterProgressClient: %s" % reason_code)
encounter_sync_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var snapshot: Variant = parse_encounter_progress_json(text)
if snapshot == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("EncounterProgressClient: %s" % reason_json)
encounter_sync_failed.emit(reason_json)
return
encounter_progress_received.emit(snapshot as Dictionary)

View File

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

View File

@ -54,6 +54,8 @@ const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
const SALVAGE_SKILL_ID := "salvage" const SALVAGE_SKILL_ID := "salvage"
const REFINE_SKILL_ID := "refine" const REFINE_SKILL_ID := "refine"
const BREACH_GIG_ID := "breach" const BREACH_GIG_ID := "breach"
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
## Bump on each rejection so older one-shot timers do not clear a newer message. ## Bump on each rejection so older one-shot timers do not clear a newer message.
var _move_reject_msg_token: int = 0 var _move_reject_msg_token: int = 0
@ -104,6 +106,8 @@ var _last_progression_snapshot: Dictionary = {}
var _progression_error: String = "" var _progression_error: String = ""
var _last_gig_snapshot: Dictionary = {} var _last_gig_snapshot: Dictionary = {}
var _gig_error: String = "" var _gig_error: String = ""
var _last_encounter_progress_snapshot: Dictionary = {}
var _encounter_progress_error: String = ""
var _gather_pre_scrap_qty: int = 0 var _gather_pre_scrap_qty: int = 0
var _gather_pending_interactable_id: String = "" var _gather_pending_interactable_id: String = ""
var _gather_awaiting_inventory_finalize: bool = false var _gather_awaiting_inventory_finalize: bool = false
@ -126,6 +130,8 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel @onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel
@onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel @onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel @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 _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel @onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel @onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
@ -141,6 +147,7 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP
@onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _item_defs_client: Node = $ItemDefinitionsClient
@onready var _skill_progression_client: Node = $SkillProgressionClient @onready var _skill_progression_client: Node = $SkillProgressionClient
@onready var _gig_progression_client: Node = $GigProgressionClient @onready var _gig_progression_client: Node = $GigProgressionClient
@onready var _encounter_progress_client: Node = $EncounterProgressClient
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient @onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
@onready var _craft_client: Node = $CraftClient @onready var _craft_client: Node = $CraftClient
@onready var _target_client: Node = $TargetSelectionClient @onready var _target_client: Node = $TargetSelectionClient
@ -189,6 +196,7 @@ func _ready() -> void:
_setup_inventory_sync() _setup_inventory_sync()
_setup_skill_progression_sync() _setup_skill_progression_sync()
_setup_gig_progression_sync() _setup_gig_progression_sync()
_setup_encounter_progress_sync()
_setup_gather_interact_feedback() _setup_gather_interact_feedback()
_setup_craft_ui() _setup_craft_ui()
@ -471,10 +479,10 @@ func _apply_authority_http_config_to_client(client: Node) -> void:
func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void:
_populate_craft_recipe_panel_if_ready() _populate_craft_recipe_panel_if_ready()
if not _inventory_error.is_empty(): if _inventory_error.is_empty() and not _last_inventory_snapshot.is_empty():
return
if not _last_inventory_snapshot.is_empty():
_render_inventory_label() _render_inventory_label()
if _encounter_progress_error.is_empty() and not _last_encounter_progress_snapshot.is_empty():
_render_encounter_progress_labels()
func _on_inventory_received(snapshot: Dictionary) -> void: func _on_inventory_received(snapshot: Dictionary) -> void:
@ -705,6 +713,135 @@ func _request_gig_progression_refresh() -> void:
_gig_progression_client.call("request_sync_from_server") _gig_progression_client.call("request_sync_from_server")
func _setup_encounter_progress_sync() -> void:
# NEO-110: encounter progress + loot HUD; boot hydrate + refresh after NPC defeat cast.
_apply_authority_http_config_to_client(_encounter_progress_client)
if _encounter_progress_client.has_signal("encounter_progress_received"):
_encounter_progress_client.connect(
"encounter_progress_received", Callable(self, "_on_encounter_progress_received")
)
if _encounter_progress_client.has_signal("encounter_sync_failed"):
_encounter_progress_client.connect(
"encounter_sync_failed", Callable(self, "_on_encounter_progress_sync_failed")
)
_render_encounter_progress_labels()
if _encounter_progress_client.has_method("request_sync_from_server"):
_encounter_progress_client.call("request_sync_from_server")
func _on_encounter_progress_received(snapshot: Dictionary) -> void:
_encounter_progress_error = ""
_last_encounter_progress_snapshot = snapshot.duplicate(true)
_render_encounter_progress_labels()
var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID)
if str(row.get("state", "")) == "completed":
_request_inventory_refresh()
func _on_encounter_progress_sync_failed(reason: String) -> void:
_encounter_progress_error = reason
_last_encounter_progress_snapshot = {}
_render_encounter_progress_labels()
func _render_encounter_progress_labels() -> void:
_render_encounter_progress_label()
_render_encounter_complete_label()
func _render_encounter_progress_label() -> void:
if not is_instance_valid(_encounter_progress_label):
return
var header := "Encounter:"
if not _encounter_progress_error.is_empty():
_encounter_progress_label.text = "%s\nerror — %s" % [header, _encounter_progress_error]
return
if _last_encounter_progress_snapshot.is_empty():
_encounter_progress_label.text = "%s\nLoading…" % header
return
var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID)
if row.is_empty():
_encounter_progress_label.text = (
"%s\n%s: — (missing row)" % [header, PROTOTYPE_ENCOUNTER_ID]
)
return
var state: String = str(row.get("state", ""))
var body: String = ""
match state:
"inactive":
body = (
"%s: not started (0/%d)"
% [PROTOTYPE_ENCOUNTER_ID, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS]
)
"active":
var defeated: Variant = row.get("defeatedTargetIds", [])
var n: int = defeated.size() if defeated is Array else 0
body = "%s: %d/%d" % [PROTOTYPE_ENCOUNTER_ID, n, PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS]
"completed":
body = (
"%s: completed (%d/%d)"
% [
PROTOTYPE_ENCOUNTER_ID,
PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS,
PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS,
]
)
_:
body = "%s: unknown state (%s)" % [PROTOTYPE_ENCOUNTER_ID, state]
_encounter_progress_label.text = "%s\n%s" % [header, body]
func _render_encounter_complete_label() -> void:
if not is_instance_valid(_encounter_complete_label):
return
var header := "Loot:"
if not _encounter_progress_error.is_empty():
_encounter_complete_label.text = "%s\n" % header
return
if _last_encounter_progress_snapshot.is_empty():
_encounter_complete_label.text = "%s\n" % header
return
var row: Dictionary = _encounter_row(PROTOTYPE_ENCOUNTER_ID)
if row.is_empty() or str(row.get("state", "")) != "completed":
_encounter_complete_label.text = "%s\n" % header
return
var lines: PackedStringArray = [header]
var completed_at: String = str(row.get("completedAt", "")).strip_edges()
if not completed_at.is_empty():
lines[0] = "%s (completed %s)" % [header, completed_at]
var grants: Variant = row.get("rewardGrantSummary", [])
if grants is Array:
for grant_variant in grants as Array:
if not grant_variant is Dictionary:
continue
var grant: Dictionary = grant_variant
var item_id: String = str(grant.get("itemId", ""))
var qty: int = int(grant.get("quantity", 0))
var label: String = _inventory_item_label(item_id) if not item_id.is_empty() else "?"
lines.append(" %s ×%d" % [label, qty])
if lines.size() == 1:
lines.append(" (no grants)")
_encounter_complete_label.text = "\n".join(lines)
func _encounter_row(encounter_id: String) -> Dictionary:
if not is_instance_valid(_encounter_progress_client):
return {}
if not _encounter_progress_client.has_method("encounter_row"):
return {}
var row: Variant = _encounter_progress_client.call(
"encounter_row", encounter_id, _last_encounter_progress_snapshot
)
return row as Dictionary
func _request_encounter_progress_refresh() -> void:
if not is_instance_valid(_encounter_progress_client):
return
if _encounter_progress_client.has_method("request_sync_from_server"):
_encounter_progress_client.call("request_sync_from_server")
func _render_gather_feedback_label(text: String = "Gather: —") -> void: func _render_gather_feedback_label(text: String = "Gather: —") -> void:
if is_instance_valid(_gather_feedback_label): if is_instance_valid(_gather_feedback_label):
_gather_feedback_label.text = text _gather_feedback_label.text = text
@ -1005,6 +1142,7 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D
_request_npc_combat_sync() _request_npc_combat_sync()
if bool(resolution.get("targetDefeated", false)): if bool(resolution.get("targetDefeated", false)):
_request_gig_progression_refresh() _request_gig_progression_refresh()
_request_encounter_progress_refresh()
return return
var rc := reason_code.strip_edges() var rc := reason_code.strip_edges()
if rc.is_empty(): if rc.is_empty():

View File

@ -0,0 +1,168 @@
extends GdUnitTestSuite
## NEO-110: `EncounterProgressClient` GET parse + failure signals.
const EncounterProgressClient := preload("res://scripts/encounter_progress_client.gd")
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
var _progress_capture: Dictionary = {}
func _capture_progress(snapshot: Dictionary) -> void:
_progress_capture = snapshot
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 _inactive_progression_json() -> String:
return (
'{"schemaVersion":1,"playerId":"dev-local-1","encounters":'
+ '[{"encounterId":"prototype_combat_pocket","state":"inactive","defeatedTargetIds":[]}]}'
)
static func _active_two_defeats_json() -> String:
return (
'{"schemaVersion":1,"playerId":"dev-local-1","encounters":'
+ '[{"encounterId":"prototype_combat_pocket","state":"active",'
+ '"defeatedTargetIds":["prototype_npc_melee","prototype_npc_ranged"]}]}'
)
static func _completed_with_grants_json() -> String:
return (
'{"schemaVersion":1,"playerId":"dev-local-1","encounters":'
+ '[{"encounterId":"prototype_combat_pocket","state":"completed",'
+ '"defeatedTargetIds":["prototype_npc_elite","prototype_npc_melee","prototype_npc_ranged"],'
+ '"completedAt":"2026-05-31T12:00:00Z",'
+ '"rewardGrantSummary":[{"itemId":"scrap_metal_bulk","quantity":10},'
+ '{"itemId":"contract_handoff_token","quantity":1}]}]}'
)
func _make_client(transport: Node) -> Node:
var c: Node = EncounterProgressClient.new()
c.set("injected_http", transport)
auto_free(transport)
auto_free(c)
add_child(c)
return c
func test_parse_inactive_encounter_row() -> void:
# Arrange
var json := _inactive_progression_json()
# Act
var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var c: Node = EncounterProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary)
assert_that(str(row.get("state", ""))).is_equal("inactive")
assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(0)
func test_parse_active_row_has_two_defeats() -> void:
# Arrange
var json := _active_two_defeats_json()
# Act
var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var c: Node = EncounterProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary)
assert_that(str(row.get("state", ""))).is_equal("active")
assert_that((row.get("defeatedTargetIds", []) as Array).size()).is_equal(2)
func test_parse_completed_row_has_reward_grant_summary() -> void:
# Arrange
var json := _completed_with_grants_json()
# Act
var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json)
# Assert
assert_that(snapshot is Dictionary).is_true()
var c: Node = EncounterProgressClient.new()
auto_free(c)
var row: Dictionary = c.call("encounter_row", PROTOTYPE_ENCOUNTER_ID, snapshot as Dictionary)
assert_that(str(row.get("state", ""))).is_equal("completed")
var grants: Variant = row.get("rewardGrantSummary", null)
assert_that(grants is Array).is_true()
assert_that((grants as Array).size()).is_equal(2)
func test_request_sync_emits_encounter_progress_received() -> void:
# Arrange
_progress_capture = {}
var transport := MockHttpTransport.new()
transport.body_json = _inactive_progression_json()
var c := _make_client(transport)
c.connect("encounter_progress_received", Callable(self, "_capture_progress"))
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("encounter_progress_received", any())
assert_that(_progress_capture.get("playerId", "")).is_equal("dev-local-1")
assert_that(transport.last_url).contains("/encounter-progress")
func test_http_404_emits_encounter_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.response_code = 404
var c := _make_client(transport)
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted("encounter_sync_failed", "HTTP 404 (player unknown)")
func test_parse_encounter_progress_json_returns_null_for_schema_mismatch() -> void:
# Arrange
var json := '{"schemaVersion":2,"playerId":"dev-local-1","encounters":[]}'
# Act
var snapshot: Variant = EncounterProgressClient.parse_encounter_progress_json(json)
# Assert
assert_that(snapshot).is_null()
func test_invalid_schema_emits_encounter_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = '{"schemaVersion":2,"playerId":"dev-local-1","encounters":[]}'
var c := _make_client(transport)
monitor_signals(c)
# Act
c.call("request_sync_from_server")
# Assert
await assert_signal(c).is_emitted(
"encounter_sync_failed", "non-JSON body or schemaVersion mismatch"
)

View File

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

View File

@ -13,6 +13,7 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
| [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | | [`npc-behaviors/`](npc-behaviors/) | NPC behavior archetype catalogs; each row matches [`schemas/npc-behavior-def.schema.json`](schemas/npc-behavior-def.schema.json) — **stable `id`**, **`archetypeKind`**, aggro/leash radii, telegraph + attack timings for [E5.M2](../docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) |
| [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) | | [`encounters/`](encounters/) | Encounter templates; each row matches [`schemas/encounter-def.schema.json`](schemas/encounter-def.schema.json) — **stable `id`**, completion criteria, **`rewardTableId`** for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
| [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) | | [`reward-tables/`](reward-tables/) | Reward table catalogs; each row matches [`schemas/reward-table.schema.json`](schemas/reward-table.schema.json) — **stable `id`**, **`fixedGrants`** referencing frozen item ids for [E5.M3](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md) |
| [`quests/`](quests/) | Quest catalogs; each row matches [`schemas/quest-def.schema.json`](schemas/quest-def.schema.json) (steps via [`quest-step-def.schema.json`](schemas/quest-step-def.schema.json), objectives via [`quest-objective-def.schema.json`](schemas/quest-objective-def.schema.json)) — **stable `id`**, **`prerequisiteQuestIds`**, step/objective graph for [E7.M1](../docs/decomposition/modules/E7_M1_QuestStateMachine.md) |
| [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | | [`resource-nodes/`](resource-nodes/) | Resource node + yield catalogs; node rows match [`schemas/resource-node-def.schema.json`](schemas/resource-node-def.schema.json), yield rows match [`schemas/resource-yield-row.schema.json`](schemas/resource-yield-row.schema.json) — **stable `nodeDefId`**, **`gatherLens`**, **`maxGathers`** for [E3.M1](../docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) |
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring. **Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
@ -29,6 +30,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
**Prototype E5 Slice 3 — encounters + rewards (NEO-100):** CI expects **exactly one** encounter id **`prototype_combat_pocket`** and one reward table id **`prototype_combat_pocket_clear`** aligned to [E5.M3 encounter freeze](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). **`requiredNpcInstanceIds`** must match the three E5.M2 NPC instance ids; **`fixedGrants`** reference frozen item ids only. **Do not rename** ids after ship—change **`displayName`** only. See [E5M3-prototype-backlog.md](../docs/plans/E5M3-prototype-backlog.md). **Prototype E5 Slice 3 — encounters + rewards (NEO-100):** CI expects **exactly one** encounter id **`prototype_combat_pocket`** and one reward table id **`prototype_combat_pocket_clear`** aligned to [E5.M3 encounter freeze](../docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01). **`requiredNpcInstanceIds`** must match the three E5.M2 NPC instance ids; **`fixedGrants`** reference frozen item ids only. **Do not rename** ids after ship—change **`displayName`** only. See [E5M3-prototype-backlog.md](../docs/plans/E5M3-prototype-backlog.md).
**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 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). **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. Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.

View File

@ -0,0 +1,120 @@
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "Intro: Salvage Run",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "gather_intro_step_salvage",
"displayName": "Gather scrap metal",
"objectives": [
{
"id": "gather_intro_obj_scrap",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 3
}
]
}
]
},
{
"id": "prototype_quest_refine_intro",
"displayName": "Intro: Refine Stock",
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
"steps": [
{
"id": "refine_intro_step_craft",
"displayName": "Refine scrap",
"objectives": [
{
"id": "refine_intro_obj_recipe",
"kind": "craft_recipe",
"recipeId": "refine_scrap_standard",
"quantity": 1
}
]
}
]
},
{
"id": "prototype_quest_combat_intro",
"displayName": "Intro: Clear the Pocket",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "combat_intro_step_encounter",
"displayName": "Clear the combat pocket",
"objectives": [
{
"id": "combat_intro_obj_encounter",
"kind": "encounter_complete",
"encounterId": "prototype_combat_pocket"
}
]
}
]
},
{
"id": "prototype_quest_operator_chain",
"displayName": "Operator Chain",
"prerequisiteQuestIds": [
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro"
],
"steps": [
{
"id": "chain_step_gather",
"displayName": "Bulk salvage",
"objectives": [
{
"id": "chain_obj_gather",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 5
}
]
},
{
"id": "chain_step_refine",
"displayName": "Refine stock",
"objectives": [
{
"id": "chain_obj_refine",
"kind": "craft_recipe",
"recipeId": "refine_scrap_standard",
"quantity": 1
}
]
},
{
"id": "chain_step_stim",
"displayName": "Craft field stim",
"objectives": [
{
"id": "chain_obj_stim",
"kind": "craft_recipe",
"recipeId": "make_field_stim_mk0",
"quantity": 1
}
]
},
{
"id": "chain_step_token",
"displayName": "Hand off contract token",
"objectives": [
{
"id": "chain_obj_token",
"kind": "inventory_has_item",
"itemId": "contract_handoff_token",
"quantity": 1
}
]
}
]
}
]
}

View File

@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/quest-def.json",
"title": "QuestDef",
"description": "Single quest row for catalogs (e.g. content/quests/*_quests.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E7_M1_QuestStateMachine.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "prerequisiteQuestIds", "steps"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable quest key for accept/progress APIs and telemetry."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"prerequisiteQuestIds": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"description": "Quest ids that must be completed before accept; may be empty."
},
"steps": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "https://neon-sprawl.local/schemas/quest-step-def.json"
},
"description": "Ordered steps; prototype Slice 1 uses sequential advance only."
}
}
}

View File

@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/quest-objective-def.json",
"title": "QuestObjectiveDef",
"description": "Single objective row on a QuestStepDef (content/quests/*_quests.json). Kinds cover gather, craft, encounter, and inventory checks for E7.M1 prototype Slice 1.",
"type": "object",
"additionalProperties": false,
"required": ["id", "kind"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable objective key for progress tracking and HTTP projection."
},
"kind": {
"type": "string",
"enum": ["gather_item", "craft_recipe", "encounter_complete", "inventory_has_item"],
"description": "Objective completion predicate evaluated server-side on success hooks."
},
"itemId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Frozen item catalog id (gather_item, inventory_has_item)."
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "Required count for gather_item, craft_recipe, or inventory_has_item."
},
"recipeId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Frozen recipe catalog id (craft_recipe)."
},
"encounterId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Frozen encounter catalog id (encounter_complete)."
}
},
"oneOf": [
{
"properties": { "kind": { "const": "gather_item" } },
"required": ["itemId", "quantity"]
},
{
"properties": { "kind": { "const": "craft_recipe" } },
"required": ["recipeId", "quantity"]
},
{
"properties": { "kind": { "const": "encounter_complete" } },
"required": ["encounterId"]
},
{
"properties": { "kind": { "const": "inventory_has_item" } },
"required": ["itemId", "quantity"]
}
]
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/quest-step-def.json",
"title": "QuestStepDef",
"description": "Single step row on a QuestDef (content/quests/*_quests.json). Steps advance sequentially in prototype Slice 1.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "objectives"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable step key for progress tracking and HTTP projection."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"objectives": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "https://neon-sprawl.local/schemas/quest-objective-def.json"
},
"description": "Objectives that must complete before the step advances."
}
}
}

View File

@ -46,14 +46,18 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
## Implementation Slices (Backlog-Ready) ## Implementation Slices (Backlog-Ready)
<a id="epic-7-slice-1"></a>
### Slice 1 - Quest core and persistence ### Slice 1 - Quest core and persistence
- Scope: E7.M1 supporting branching steps and failure/reset rules for prototype chain. - Scope: E7.M1 supporting branching steps and failure/reset rules for prototype chain.
- Dependencies: E3.M2, E5.M1 - Dependencies: E3.M2, E5.M1 (gather/craft/encounter hooks on main)
- Acceptance criteria: - Acceptance criteria:
- 3-5 onboarding quests + one chain requiring gather/craft/combat complete without duped rewards. - 3-5 onboarding quests + one chain requiring gather/craft/combat complete without duped rewards.
- Telemetry hooks: `quest_start`, `quest_step_complete`, `quest_complete`, funnel times. - Telemetry hooks: `quest_start`, `quest_step_complete`, `quest_complete`, funnel times.
**Linear backlog:** [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) — [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). Verify in **Godot**, not Bruno-only.
### Slice 2 - Reward and unlock routing ### Slice 2 - Reward and unlock routing
- Scope: E7.M2 delivering XP, items, blueprint unlocks, and flags for world gates. - Scope: E7.M2 delivering XP, items, blueprint unlocks, and flags for world gates.
@ -85,7 +89,9 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
- Risk: Rewards break parity or economy. - Risk: Rewards break parity or economy.
- Mitigation: Lint bundles against E6.M4 and E3.M5 policies. - Mitigation: Lint bundles against E6.M4 and E3.M5 policies.
<a id="epic-7-definition-of-done"></a>
## Definition of Done ## Definition of Done
- Prototype quest counts and integrated chain meet master plan minimums. - 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.
- Pre-production adds faction + contracts with validation. - Pre-production adds faction + contracts with validation.

View File

@ -59,6 +59,8 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
**Encounter catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/encounters/*_encounters.json` against [`content/schemas/encounter-def.schema.json`](../../../content/schemas/encounter-def.schema.json), rejects **duplicate `id`** across files, cross-checks **`rewardTableId`** against reward table catalogs, and (E5 Slice 3) enforces the **frozen one-encounter** id set (`prototype_combat_pocket`) with **`requiredNpcInstanceIds`** matching the three E5.M2 NPC instance ids. **Encounter catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/encounters/*_encounters.json` against [`content/schemas/encounter-def.schema.json`](../../../content/schemas/encounter-def.schema.json), rejects **duplicate `id`** across files, cross-checks **`rewardTableId`** against reward table catalogs, and (E5 Slice 3) enforces the **frozen one-encounter** id set (`prototype_combat_pocket`) with **`requiredNpcInstanceIds`** matching the three E5.M2 NPC instance ids.
**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.
**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. **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 ## Source anchors

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M3 | | **Module ID** | E5.M3 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Status** | Ready — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) HTTP read landed · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores landed · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants landed · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) per-player GET landed · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) event record landed · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) telemetry hooks landed · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) client HUD landed · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) playable capstone landed |
| **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
## Purpose ## Purpose
@ -84,6 +84,24 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
**Injectable registries landed ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI in `AddEncounterAndRewardCatalogs`; game/HTTP callers should inject registries, not catalog types. Plan: [NEO-102 implementation plan](../../plans/NEO-102-implementation-plan.md). **Injectable registries landed ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI in `AddEncounterAndRewardCatalogs`; game/HTTP callers should inject registries, not catalog types. Plan: [NEO-102 implementation plan](../../plans/NEO-102-implementation-plan.md).
**HTTP read model landed ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + DTOs in `Game/Encounters/`; nested **`rewardTable`** summary via **`IRewardTableDefinitionRegistry`**. Plan: [NEO-103 implementation plan](../../plans/NEO-103-implementation-plan.md); [server README — Encounter definitions](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`.
**Progress + completion stores landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** (in-memory) + **`EncounterProgressOperations`** — activation, order-independent defeat tracking, idempotent completion flag; combat wiring NEO-106, HTTP read NEO-108. Plan: [NEO-104 implementation plan](../../plans/NEO-104-implementation-plan.md); [server README — Encounter progress](../../../server/README.md#encounter-progress--completion-stores-neo-104).
**Completion + inventory grants landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations.TryCompleteAndGrant`** + **`EncounterCompleteEvent`** result payload — reward-table fixed grants via **`PlayerInventoryOperations`**, fail-closed inventory deny, idempotent completion mark; event record persisted via **`IEncounterCompleteEventStore`** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)). Plan: [NEO-105 implementation plan](../../plans/NEO-105-implementation-plan.md); [server README — Encounter completion](../../../server/README.md#encounter-completion--inventory-grants-neo-105).
**Combat wiring landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring.TryProcessCastOutcome`** on **`AbilityCastApi`** — activate on first damaging hit, mark defeats, invoke completion grants when all required NPCs defeated; NEO-44 gig XP preserved. Plan: [NEO-106 implementation plan](../../plans/NEO-106-implementation-plan.md); [server README — Encounter combat wiring](../../../server/README.md#encounter-combat-wiring-neo-106).
**Complete event record landed ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + in-memory implementation — idempotent **`EncounterCompleteEvent`** record on successful grant commit; E7.M2 **`reward_delivery`** hook stub (comments only). Plan: [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).
**Per-player progress HTTP landed ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — **`EncounterProgressApi`** + DTOs; projects progress, completion, and event stores; **`rewardGrantSummary`** from commit-time **`GrantedItems`**. Plan: [NEO-108 implementation plan](../../plans/NEO-108-implementation-plan.md); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. Client counterpart: [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110).
**Slice 3 telemetry hooks landed ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, and **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`**; **`EncounterCombatWiring`** delegates without duplicate hooks. Plan: [NEO-109 implementation plan](../../plans/NEO-109-implementation-plan.md); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109).
**Client encounter HUD landed ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** Godot **`encounter_progress_client.gd`** polls **`GET /game/players/{id}/encounter-progress`** on boot and after defeat casts; **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** in combat HUD; inventory refresh on completion. Plan: [NEO-110 implementation plan](../../plans/NEO-110-implementation-plan.md); manual QA: [NEO-110.md](../../manual-qa/NEO-110.md); [client README — Encounter progress + loot HUD](../../../client/README.md#encounter-progress--loot-hud-neo-110).
**Client capstone landed ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop in Godot — [`NEO-111.md`](../../manual-qa/NEO-111.md) single-session script (flexible defeat order, Godot restart idempotency); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111). Plan: [NEO-111 implementation plan](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.**
## Source anchors ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.

View File

@ -7,7 +7,8 @@
| **Module ID** | E7.M1 | | **Module ID** | E7.M1 |
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) | | **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) | | **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**; registry + runtime from **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
## Purpose ## Purpose
@ -45,6 +46,42 @@ Data-driven quest lifecycle: start, step progression, branching, failure/reset,
See Epic 7 **Slice 1 — Quest core and persistence**: 35 onboarding quests plus one chain across gather/craft/combat; telemetry `quest_start`, `quest_step_complete`, `quest_complete`, funnel times. See Epic 7 **Slice 1 — Quest core and persistence**: 35 onboarding quests plus one chain across gather/craft/combat; telemetry `quest_start`, `quest_step_complete`, `quest_complete`, funnel times.
## Linear backlog (decomposed)
Full story tables, kickoff defaults, and **`blockedBy` graph:** [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md).
| Slug | Linear |
|------|--------|
| E7M1-01 | [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) |
| E7M1-02 | [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) |
| E7M1-03 | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) |
| E7M1-04 | [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) |
| E7M1-05 | [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) |
| E7M1-06 | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) |
| E7M1-07 | [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) |
| E7M1-08 | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) |
| E7M1-09 | [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) |
| E7M1-10 | [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) |
| E7M1-11 | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) |
| E7M1-12 | [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
## Prototype Slice 1 freeze (E7M1-01)
The **first shipped quest spine** is **frozen** for prototype tuning until a deliberate migration issue expands the roster.
| `QuestDef.id` | `displayName` | `prerequisiteQuestIds` | Steps (summary) |
|---------------|---------------|------------------------|-----------------|
| **`prototype_quest_gather_intro`** | Intro: Salvage Run | *(none)* | 1× **`gather_item`**: **`scrap_metal_bulk` ×3** |
| **`prototype_quest_refine_intro`** | Intro: Refine Stock | **`prototype_quest_gather_intro`** | 1× **`craft_recipe`**: **`refine_scrap_standard`** ×1 |
| **`prototype_quest_combat_intro`** | Intro: Clear the Pocket | *(none)* | 1× **`encounter_complete`**: **`prototype_combat_pocket`** |
| **`prototype_quest_operator_chain`** | Operator Chain | **`prototype_quest_gather_intro`**, **`prototype_quest_refine_intro`**, **`prototype_quest_combat_intro`** | 4 steps: gather **`scrap_metal_bulk` ×5** → craft **`refine_scrap_standard`** → craft **`make_field_stim_mk0`** → **`inventory_has_item`** **`contract_handoff_token` ×1** |
**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).
**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.
## Risks and telemetry ## Risks and telemetry
- Scripting weight: keep `QuestDef` data-first; templates in decomposition/tooling path per epic. - Scripting weight: keep `QuestDef` data-first; templates in decomposition/tooling path per epic.

View File

@ -48,3 +48,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
## Implementation anchor (server) ## Implementation anchor (server)
**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-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).

File diff suppressed because one or more lines are too long

View File

@ -73,14 +73,14 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---| |---|---|---|---|---|---|
| E5.M1 | CombatRulesEngine | E1.M3, E2.M2 | CombatAction, CombatResolution, ThreatState | Prototype | Ready | | E5.M1 | CombatRulesEngine | E1.M3, E2.M2 | CombatAction, CombatResolution, ThreatState | Prototype | Ready |
| E5.M2 | NpcAiAndBehaviorProfiles | E5.M1 | NpcBehaviorDef, TelegraphEvent, AggroRule | Prototype | In Progress | | E5.M2 | NpcAiAndBehaviorProfiles | E5.M1 | NpcBehaviorDef, TelegraphEvent, AggroRule | Prototype | In Progress |
| E5.M3 | EncounterAndRewardTables | E5.M1, E5.M2, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | In Progress | | E5.M3 | EncounterAndRewardTables | E5.M1, E5.M2, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Ready |
| E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned | | E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned |
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91 plan](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 complete — register row Ready.** **E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91 plan](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 complete — register row Ready.**
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. **E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **Runtime/engine (NEO-103+) not started.** **E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** ( **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** ); idempotent completion per player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **NEO-103 landed:** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103 plan](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **NEO-104 landed:** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104 plan](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **NEO-105 landed:** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105 plan](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **NEO-106 landed:** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106 plan](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **NEO-107 landed:** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107 plan](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **NEO-108 landed:** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108 plan](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **NEO-109 landed:** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** hook sites ([NEO-109 plan](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **NEO-110 landed:** client encounter progress + loot HUD — **`encounter_progress_client.gd`**, defeat-triggered GET ([NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **NEO-111 landed:** playable encounter clear capstone — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 complete — register row Ready.**
### Epic 6 — PvP Security ### Epic 6 — PvP Security
@ -108,6 +108,8 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status | | 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 | Planned |
**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; register row stays **Planned** until **NEO-114** registry + quest runtime land.
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned | | E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned | | 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 | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |

View File

@ -0,0 +1,59 @@
# NEO-110 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-110 |
| Title | E5M3-11: Client encounter progress + loot feedback HUD |
| Linear | https://linear.app/neon-sprawl/issue/NEO-110/e5m3-11-client-encounter-progress-loot-feedback-hud |
| Plan | `docs/plans/NEO-110-implementation-plan.md` |
| Branch | `NEO-110-client-encounter-progress-loot-feedback-hud` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory encounter progress, NPC HP, and inventory reset.
- **No Bruno/curl** for this checklist — defeat chain uses Godot **Tab** + **1** only.
- NEO-108 encounter-progress GET and NEO-106 combat wiring landed on `main`.
- **`Economy HUD`** toggle **on** for inventory verification (step 8).
## Archetype reference (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 encounter HUD progression
| After | `EncounterProgressLabel` | `EncounterCompleteLabel` |
|-------|--------------------------|---------------------------|
| Boot (no engagement) | `prototype_combat_pocket: not started (0/3)` | `Loot: —` |
| 1st NPC defeated | `prototype_combat_pocket: 1/3` | `Loot: —` |
| 2nd NPC defeated | `prototype_combat_pocket: 2/3` | `Loot: —` |
| 3rd NPC defeated | `prototype_combat_pocket: completed (3/3)` | `scrap_metal_bulk ×10` and `contract_handoff_token ×1` (display names when item defs loaded) |
## 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`**).
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).
6. Open **`Economy HUD`** body: **`InventoryLabel`** shows **`scrap_metal_bulk`** and **`contract_handoff_token`** without pressing **I** (auto-refresh on completion GET).
7. Defeat any already-defeated NPC again (optional): encounter row stays **`completed (3/3)`**; loot lines unchanged.
8. Regression: **`GigXpLabel`** still updates per defeat (NEO-86); **`CastFeedbackLabel`** still shows deny on re-cast (**`target_defeated`**).
## Notes
- Defeat order may differ from table — server tracks order-independent subset; count is **`defeatedTargetIds.size()`**.
- Capstone baseline prefers **server restart** over mid-session fixture POST.
- Full three-NPC loop + idempotency capstone: [NEO-111 manual QA](NEO-111.md).
- Component combat regression: [NEO-98 manual QA](NEO-98.md).
## Acceptance
- [ ] Steps 18 completable in one session without Bruno/curl.
- [ ] Progress label shows **1/3**, **2/3**, then **completed (3/3)** after defeats.
- [ ] Loot label matches server grant summary; inventory reflects grants without **I**.

View File

@ -0,0 +1,90 @@
# NEO-111 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-111 |
| Title | E5M3-12: Playable encounter clear capstone (Godot) |
| Linear | https://linear.app/neon-sprawl/issue/NEO-111/e5m3-12-playable-encounter-clear-capstone-godot |
| Plan | `docs/plans/NEO-111-implementation-plan.md` |
| Branch | `NEO-111-playable-encounter-clear-capstone` |
## Preconditions
- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** NPC HP, encounter progress, inventory, and gig XP reset.
- **No Bruno/curl** for this checklist — defeat chain uses Godot **Tab** + **1** only.
- NEO-110 encounter HUD, NEO-106 combat wiring, and NEO-108 encounter-progress GET landed on `main`.
- **`Economy HUD`** toggle **on** for inventory verification.
## Archetype reference (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). Defeat all three in **any order** — server tracks an order-independent subset.
## Expected encounter HUD progression
| After | `EncounterProgressLabel` | `EncounterCompleteLabel` |
|-------|--------------------------|---------------------------|
| Boot (no engagement) | `prototype_combat_pocket: not started (0/3)` | `Loot: —` |
| 1st NPC defeated | `prototype_combat_pocket: 1/3` | `Loot: —` |
| 2nd NPC defeated | `prototype_combat_pocket: 2/3` | `Loot: —` |
| 3rd NPC defeated | `prototype_combat_pocket: completed (3/3)` | `scrap_metal_bulk ×10` and `contract_handoff_token ×1` (display names when item defs loaded) |
| After Godot restart (idempotency) | still **`completed (3/3)`** | unchanged grant lines |
## Expected inventory after completion
On a fresh server restart the bag starts empty. After the third defeat, **`InventoryLabel`** should show at least:
| Item id | Quantity |
|---------|----------|
| `scrap_metal_bulk` | **10** |
| `contract_handoff_token` | **1** |
Record these quantities before idempotency steps.
## Expected gig XP after three defeats
**`GigXpLabel`** should show **`breach: L1 · 75 xp`** (NEO-44: **25** gig XP per defeat × **3**). No further gig XP increase during idempotency steps.
## Checklist
### Boot + encounter clear
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`**).
3. Defeat **first** prototype NPC (any archetype): walk to marker, **Tab** lock, press **1** until **`Cast: … — defeated!`**. Verify **`EncounterProgressLabel`** shows **`1/3`** (may lag one GET behind cast — wait ~1 s).
4. Defeat **second** prototype NPC (either remaining archetype): lock, cast until defeated. Verify **`2/3`**.
5. Defeat **third** prototype NPC: lock, cast until defeated (**8** pulses for Elite). Verify **`completed (3/3)`** and **`EncounterCompleteLabel`** lists **`scrap_metal_bulk ×10`** and **`contract_handoff_token ×1`** (or raw ids if defs still loading).
6. Open **`Economy HUD`** body: **`InventoryLabel`** shows **`scrap_metal_bulk`** (**10**) and **`contract_handoff_token`** (**1**) without pressing **I** (auto-refresh on completion GET).
7. Verify **`GigXpLabel`** shows **`breach: L1 · 75 xp`**.
### Idempotency
8. **Record** inventory quantities for **`scrap_metal_bulk`** and **`contract_handoff_token`** from step 6.
9. **Godot restart (re-login):** stop Godot run (**Shift+F5** or stop button); **F5** again **without** stopping the server. After boot hydrate, verify **`EncounterProgressLabel`** still **`completed (3/3)`** and **`EncounterCompleteLabel`** grant lines unchanged.
10. Verify **`InventoryLabel`** quantities **match step 8** — no duplicate loot.
11. Lock a **defeated** NPC (**Tab** at its marker); press **1**. Verify **`CastFeedbackLabel`** shows **`ability_cast_denied: target_defeated`**; encounter HUD and inventory **unchanged** from steps 910.
12. Verify **`GigXpLabel`** still **`breach: L1 · 75 xp`** — no extra gig XP from idempotency casts.
### Regression
13. **`CastFeedbackLabel`** still shows readable deny reasons (NEO-28 / NEO-98).
14. Component encounter HUD detail: [NEO-110 manual QA](NEO-110.md). Component NPC combat: [NEO-98 manual QA](NEO-98.md).
## Notes
- Defeat order may differ from the archetype table — only **0/3 → 3/3** progression is asserted.
- Capstone baseline prefers **server restart** over mid-session fixture POST.
- **`POST /game/__dev/combat-targets-fixture`** is **out of scope** for idempotency — it clears encounter progress/completion alongside NPC HP reset ([server README — dev combat-target fixture](../../server/README.md#dev-combat-target-fixture-brunomanual-qa)).
- Slice 3 Epic AC: completing **`prototype_combat_pocket`** grants expected items + quest token **once** ([epic_05 Slice 3](../../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3)).
## Acceptance
- [x] Steps 114 completable in one session without Bruno/curl.
- [x] Progress label shows **1/3**, **2/3**, then **completed (3/3)** after three defeats (any order).
- [x] Loot label and inventory show **`scrap_metal_bulk` ×10** + **`contract_handoff_token` ×1**; quantities unchanged after Godot restart + defeated-NPC casts.
- [x] Epic 5 Slice 3 AC satisfied in Godot.

View File

@ -161,8 +161,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] GET returns frozen encounter in ascending `id` order with `schemaVersion` **1**. - [x] GET returns frozen encounter in ascending `id` order with `schemaVersion` **1**.
- [ ] Bruno happy GET passes in CI Bruno step. - [x] Bruno happy GET passes in CI Bruno step.
**Landed ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** `GET /game/world/encounter-definitions` + nested `rewardTable` summary; Bruno + AAA integration test; plan [NEO-103-implementation-plan.md](NEO-103-implementation-plan.md).
--- ---
@ -183,9 +185,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Defeating required NPCs accumulates progress in any order. - [x] Defeating required NPCs accumulates progress in any order.
- [ ] Completion predicate true only when all required ids defeated. - [x] Completion predicate true only when all required ids defeated.
- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer. - [x] Second completion attempt on same player+encounter is idempotent no-op at store layer.
**Landed ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** `IEncounterProgressStore` + `IEncounterCompletionStore` + `EncounterProgressOperations`; in-memory DI; AAA unit tests; plan [NEO-104-implementation-plan.md](NEO-104-implementation-plan.md).
--- ---
@ -207,9 +211,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically. - [x] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
- [ ] Second completion does not duplicate grants. - [x] Second completion does not duplicate grants.
- [ ] Inventory deny fails closed without marking complete. - [x] Inventory deny fails closed without marking complete.
**Landed ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** `EncounterCompletionOperations.TryCompleteAndGrant` + `EncounterCompleteEvent` result payload; AAA unit tests; plan [NEO-105-implementation-plan.md](NEO-105-implementation-plan.md).
**Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels. **Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels.
@ -233,9 +239,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Defeating third required NPC triggers exactly one completion grant. - [x] Defeating third required NPC triggers exactly one completion grant.
- [ ] Partial progress (12 defeats) does not grant loot. - [x] Partial progress (12 defeats) does not grant loot.
- [ ] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`**. - [x] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`**.
**Landed ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`**; in-proc defeat-all-three integration tests; plan [NEO-106-implementation-plan.md](NEO-106-implementation-plan.md).
--- ---
@ -255,9 +263,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] GET reflects progress after each defeat without client-side inference. - [x] GET reflects progress after each defeat without client-side inference.
- [ ] Completed encounter shows **`completed`** state and grant summary. - [x] Completed encounter shows **`completed`** state and grant summary.
- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once. - [x] Bruno smoke: defeat chain → GET shows **`completed`** once.
**Landed ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** + Bruno defeat spine; plan [NEO-108-implementation-plan.md](NEO-108-implementation-plan.md).
**Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — poll/refresh from this GET. **Client counterpart:** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — poll/refresh from this GET.
@ -279,9 +289,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Completion path emits/records event exactly once per player+encounter. - [x] Completion path emits/records event exactly once per player+encounter.
- [ ] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract. - [x] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract.
- [ ] No runtime behavior beyond event record + comments. - [x] No runtime behavior beyond event record + comments.
**Landed ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + in-memory implementation; record on **`TryCompleteAndGrant`** success; E7.M2 **`reward_delivery`** hook comments. Plan: [NEO-107-implementation-plan.md](NEO-107-implementation-plan.md).
--- ---
@ -300,8 +312,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Hook names match [epic_05 Slice 3](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3) vocabulary. - [x] Hook names match [epic_05 Slice 3](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3) vocabulary.
- [ ] No runtime behavior change beyond comments. - [x] No runtime behavior change beyond comments.
**Landed ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`** (once per completion), **`encounter_complete_denied`**; [server README — Encounter telemetry hooks (NEO-109)](../../server/README.md#encounter-telemetry-hooks-neo-109). Plan: [NEO-109-implementation-plan.md](NEO-109-implementation-plan.md).
--- ---
@ -323,9 +337,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets). - [x] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets).
- [ ] Completion label shows grant summary matching server GET. - [x] Completion label shows grant summary matching server GET.
- [ ] Manual QA checklist exercisable without Bruno. - [x] Manual QA checklist exercisable without Bruno.
**Landed ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`** + **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory auto-refresh on **`completed`**. Plan: [NEO-110-implementation-plan.md](NEO-110-implementation-plan.md); manual QA: [NEO-110.md](../manual-qa/NEO-110.md); [client README — Encounter progress + loot HUD](../../client/README.md#encounter-progress--loot-hud-neo-110).
**Client counterpart:** this issue (**NEO-110**). **Client counterpart:** this issue (**NEO-110**).
@ -347,9 +363,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] Human completes script with server + client; loot readable in inventory HUD. - [x] Human completes script with server + client; loot readable in inventory HUD.
- [ ] Epic 5 Slice 3 AC: completing defined encounter grants expected items + quest token **once**. - [x] Epic 5 Slice 3 AC: completing defined encounter grants expected items + quest token **once**.
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums. - [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
**Landed ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** capstone manual QA [`NEO-111.md`](../manual-qa/NEO-111.md) + [client README — End-to-end encounter clear loop](../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111-implementation-plan.md](NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.**
**Client counterpart:** this issue (**NEO-111**). **Client counterpart:** this issue (**NEO-111**).

View File

@ -0,0 +1,392 @@
# E7.M1 — Prototype story backlog (QuestStateMachine)
Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../decomposition/epics/epic_07_quest_faction.md#slice-1---quest-core-and-persistence)). Decomposition and contracts: [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.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: [E5M3 (paired server+client)](../plans/E5M3-prototype-backlog.md).
**Labels (Linear):** every issue **`E7.M1`**; add **`server`** or **`client`** + **`Story`** as listed.
**Precursor (do not re-scope):** [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) encounter complete grants **`contract_handoff_token`** + records **`EncounterCompleteEvent`** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)) — prototype quest credit stand-in until [E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **`QuestRewardBundle`** router lands. **NEO-43** [`MissionRewardSkillXpGrant`](../../server/NeonSprawl.Server/Game/Skills/MissionRewardSkillXpGrant.cs) is the future skill-XP apply helper for E7.M2; E7.M1 tracks quest state only (no duplicate item grants on quest complete in Slice 1).
**Upstream (must be landed):** [E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) gather on interact ([NEO-63](https://linear.app/neon-sprawl/issue/NEO-63)); [E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) craft POST ([NEO-70](https://linear.app/neon-sprawl/issue/NEO-70)); [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) encounter wiring + progress GET ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106), [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)). [E3S5](../plans/E3S5-client-prototype-backlog.md) client economy HUD ([NEO-72](https://linear.app/neon-sprawl/issue/NEO-72)[NEO-75](https://linear.app/neon-sprawl/issue/NEO-75)).
**Prototype quest spine (frozen in E7M1-01):** **three** solo onboarding quests + **one** multi-step chain quest (vision minimum 35 onboarding + one integrated chain). **Solo only** — no party credit ([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 |
|------|-------|--------|
| E7M1-01 | server | [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) |
| E7M1-02 | server | [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) |
| E7M1-03 | server | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) |
| E7M1-04 | server | [NEO-115](https://linear.app/neon-sprawl/issue/NEO-115) |
| E7M1-05 | server | [NEO-116](https://linear.app/neon-sprawl/issue/NEO-116) |
| E7M1-06 | server | [NEO-117](https://linear.app/neon-sprawl/issue/NEO-117) |
| E7M1-07 | server | [NEO-118](https://linear.app/neon-sprawl/issue/NEO-118) |
| E7M1-08 | server | [NEO-119](https://linear.app/neon-sprawl/issue/NEO-119) |
| E7M1-09 | server | [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) |
| E7M1-10 | server | [NEO-121](https://linear.app/neon-sprawl/issue/NEO-121) |
| E7M1-11 | client | [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) |
| E7M1-12 | client | [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
**Dependency graph in Linear:** E7M1-02 blocked by E7M1-01. E7M1-03 blocked by E7M1-02. E7M1-04 blocked by E7M1-03. E7M1-05 blocked by E7M1-03. E7M1-06 blocked by E7M1-05. E7M1-07 blocked by E7M1-06. E7M1-08 blocked by E7M1-07. E7M1-09 blocked by E7M1-06 (may parallel E7M1-08). E7M1-10 blocked by E7M1-07. E7M1-11 blocked by E7M1-08 and E7M1-09. E7M1-12 blocked by E7M1-11.
**Board order:** estimates **112** matching slug order (E7M1-01 = 1 … E7M1-12 = 12). On the Epic 7 project board, sort by **Estimate** (ascending).
---
## Story order (recommended)
| Order | Slug | Layer | Depends on |
|-------|------|-------|------------|
| 1 | **E7M1-01** | server | E3.M1/E3.M2/E5.M3 content ids frozen |
| 2 | **E7M1-02** | server | E7M1-01 |
| 3 | **E7M1-03** | server | E7M1-02 |
| 4 | **E7M1-04** | server | E7M1-03 |
| 5 | **E7M1-05** | server | E7M1-03 |
| 6 | **E7M1-06** | server | E7M1-05 |
| 7 | **E7M1-07** | server | E7M1-06 |
| 8 | **E7M1-08** | server | E7M1-07 |
| 9 | **E7M1-09** | server | E7M1-06 |
| 10 | **E7M1-10** | server | E7M1-07 |
| 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.
---
## Kickoff decisions (decomposition defaults)
| Topic | Decision | Rationale |
|-------|----------|-----------|
| Quest count | **3** onboarding + **1** multi-step chain (4 total) | Vision prototype minimum; readable manual QA |
| Party mode | **Solo only** | [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md); E8.M1 not landed |
| Accept flow | **`POST …/quests/{questId}/accept`** | Explicit start; boot does not auto-grant all quests |
| Objective advance | **Server hooks** on gather/craft/encounter success | Client displays GET snapshot only ([client_server_authority.md](../decomposition/modules/client_server_authority.md)) |
| Objective kinds (v1) | **`gather_item`**, **`craft_recipe`**, **`encounter_complete`**, **`inventory_has_item`** | Covers gather→craft→combat chain without custom scripting VM |
| Chain quest | **4 sequential steps** in one `QuestDef` | Single HUD row with step index; epic “integrated chain” |
| Prerequisites | **`prerequisiteQuestIds`** enforced on accept | Gather and combat intros parallel (no prereq); refine requires gather intro; chain requires all three intros |
| Rewards (Slice 1) | **State-only completion**; no duplicate item/XP grants | Loot stays on gather/craft/encounter paths; E7.M2 owns bundles |
| Token handoff | Chain final step **`inventory_has_item`** **`contract_handoff_token`** | Uses E5.M3 encounter loot; no consume mutation in Slice 1 |
| Idempotency | **Once per player per quest id** complete | Slice 1 AC: no duped quest completion |
| Store | **In-memory** **`IPlayerQuestStateStore`** (+ optional Postgres migration) | Matches encounter prototype stores |
| HTTP read | **`GET /game/players/{id}/quest-progress`** | Client HUD without local objective math |
| HUD fidelity | Prototype Labels / debug panels (not final art) | NEO-110 / NEO-122 precedent |
---
### E7M1-01 — Prototype QuestDef catalog + schemas + CI
**Goal:** Lock content shape and CI validation for **four** frozen quests before server load.
**In scope**
- `content/schemas/quest-def.schema.json`, `content/schemas/quest-step-def.schema.json`, `content/schemas/quest-objective-def.schema.json`.
- `content/quests/prototype_quests.json` — stable quest ids and step/objective graph (see module doc freeze table).
- `scripts/validate_content.py`: schema validation, duplicate ids, exact four-quest allowlist, cross-ref **`itemId`** / **`recipeId`** / **`encounterId`** to frozen catalogs, **`prerequisiteQuestIds`** acyclic + known ids, objective kind enum.
- Designer note in [E7_M1](../decomposition/modules/E7_M1_QuestStateMachine.md) + `content/README.md`.
**Out of scope**
- Server loader, runtime engine, HTTP, Godot.
**Acceptance criteria**
- [x] PR gate validates quest JSON against schema.
- [x] Exactly four prototype quest ids; duplicate `id` fails CI.
- [x] Every objective reference resolves to a frozen catalog id.
- [x] Stable id list documented in module doc freeze box.
**Client counterpart:** none (infrastructure-only).
---
### E7M1-02 — Server quest catalog load (fail-fast)
**Goal:** Disk → host: startup load of `content/quests/*.json` with CI-parity validation.
**In scope**
- Loader + catalog types under `server/NeonSprawl.Server/Game/Quests/` (path finalized in plan).
- Fail-fast on malformed files, duplicate ids, allowlist mismatch with CI, broken cross-refs (items, recipes, encounters).
- Unit tests (AAA) for loader happy path and failure modes.
- `server/README.md` section.
**Out of scope**
- Injectable registry interface (E7M1-03), HTTP projection, runtime state machine.
**Acceptance criteria**
- [x] Host fails startup on invalid quest JSON (mirror CI rules).
- [x] Loader tests cover allowlist + cross-ref failures.
**Client counterpart:** none (infrastructure-only).
---
### E7M1-03 — Injectable quest definition registry + DI
**Goal:** **`IQuestDefinitionRegistry`** with **`TryGet`**, startup registration from E7M1-02 catalog.
**In scope**
- Interface + in-memory implementation; **`AddQuestDefinitionRegistry`** DI extension.
- Unit tests: lookup known ids, unknown id returns false.
- Wire in host startup alongside other content registries.
**Out of scope**
- HTTP, player quest state.
**Acceptance criteria**
- [ ] Registry resolves all four frozen quest ids at runtime.
- [ ] Unknown quest id fails closed for downstream operations.
**Client counterpart:** none (infrastructure-only).
---
### E7M1-04 — GET /game/world/quest-definitions
**Goal:** Versioned read-only projection of frozen quest defs for client display names and step summaries.
**In scope**
- `QuestDefinitionsWorldApi` + DTOs in `Game/Quests/`.
- Nested steps/objectives summary (ids, kinds, display copy — no server secrets).
- Bruno folder `bruno/neon-sprawl-server/quest-definitions/`.
- Integration tests (AAA).
**Out of scope**
- Per-player progress (E7M1-08).
**Acceptance criteria**
- [ ] GET returns all four prototype quests with stable JSON v1 envelope.
- [ ] Bruno request documents example response shape.
**Client counterpart:** consumed by [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) for display names (optional cache).
---
### E7M1-05 — Player quest state store + QuestStepState persistence
**Goal:** Durable per-player quest rows: status (`not_started` / `active` / `completed`), current step index, per-step objective counters.
**In scope**
- **`IPlayerQuestStateStore`** + in-memory implementation (registered in quest DI extension).
- Optional Postgres migration **`V00X`** when DB configured (mirror encounter/inventory pattern).
- **`QuestStepState`** / **`PlayerQuestProgressRow`** types aligned with module contracts.
- Unit tests: accept transitions, idempotent complete mark.
**Out of scope**
- Objective evaluation (E7M1-06), HTTP.
**Acceptance criteria**
- [ ] Store supports read/update per `(playerId, questId)`.
- [ ] Completed quest cannot regress to active without explicit reset API (none in prototype).
**Client counterpart:** none (infrastructure-only).
---
### E7M1-06 — QuestStateOperations (accept, step advance, complete)
**Goal:** Server-authoritative quest lifecycle without objective wiring yet.
**In scope**
- **`QuestStateOperations.TryAccept`**, **`TryAdvanceStep`**, **`TryMarkComplete`** with structured **`reasonCode`** denials (`unknown_quest`, `prerequisite_incomplete`, `already_completed`, `already_active`).
- Prerequisite enforcement from **`prerequisiteQuestIds`**.
- Idempotent complete: second complete returns success without state change.
- Unit tests (AAA) for accept order, prerequisite gate, idempotent complete.
**Out of scope**
- Gather/craft/encounter hooks (E7M1-07); HTTP (E7M1-08/09).
**Acceptance criteria**
- [ ] Accept fails when prerequisite quest not **completed**.
- [ ] Complete is idempotent per player+quest.
- [ ] Reason codes documented in `server/README.md`.
**Client counterpart:** [NEO-120](https://linear.app/neon-sprawl/issue/NEO-120) exposes accept POST to Godot.
---
### E7M1-07 — Quest objective wiring (gather, craft, encounter)
**Goal:** Advance active quest steps when server-side gather, craft, and encounter events succeed.
**In scope**
- **`QuestObjectiveWiring`** (or equivalent) invoked from:
- **`GatherOperations.TryGather`** success path — **`gather_item`** objectives.
- **`CraftOperations.TryCraft`** success path — **`craft_recipe`** objectives.
- **`EncounterCompletionOperations.TryCompleteAndGrant`** success path — **`encounter_complete`** objectives.
- Inventory snapshot read for **`inventory_has_item`** (post-grant or on polling hook from accept).
- Counter accumulation for quantity objectives (e.g. gather 3 scrap).
- Auto **`TryAdvanceStep`** / **`TryMarkComplete`** when step objectives satisfied.
- Integration tests: accept quest → simulate gather/craft/encounter → progress row advances.
**Out of scope**
- Client-side objective tracking; item consume on turn-in; E7.M2 reward bundles.
**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.
**Client counterpart:** progress visible via [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) GET HUD after mutations.
---
### E7M1-08 — GET /game/players/{id}/quest-progress
**Goal:** Per-player quest progress snapshot for client HUD.
**In scope**
- **`QuestProgressApi`** + DTOs: all known quests with status, current step index, objective progress counters, completion timestamp when applicable.
- Known-player gate (mirror encounter-progress / skill-progression patterns).
- Bruno `bruno/neon-sprawl-server/quest-progress/`.
- Integration tests (AAA): accept → partial progress → complete rows.
**Out of scope**
- Quest definition catalog (E7M1-04).
**Acceptance criteria**
- [ ] GET returns stable v1 envelope for **`dev-local-1`** player.
- [ ] 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.
---
### E7M1-09 — POST /game/players/{id}/quests/{questId}/accept
**Goal:** Player-initiated quest accept from Godot (prototype “quest giver” is HUD button or key binding).
**In scope**
- **`QuestAcceptApi`**: POST accept; JSON body optional v1 `{}`; response includes updated progress row or deny payload.
- Wire **`QuestStateOperations.TryAccept`**.
- Bruno + integration tests: happy accept, prerequisite deny, duplicate accept.
**Out of scope**
- Abandon/reset POST (defer unless capstone QA requires).
**Acceptance criteria**
- [ ] Successful accept transitions row **`not_started``active`**.
- [ ] Deny returns structured **`reasonCode`** without mutation.
**Client counterpart:** [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) — accept binding + feedback label.
---
### E7M1-10 — Quest telemetry hook sites (comment-only)
**Goal:** Document and place comment-only hook sites for future E9.M1 catalog events **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**.
**In scope**
- Hook sites in **`QuestStateOperations`** / **`QuestObjectiveWiring`** at accept, step advance, and complete.
- Reserved deny hook **`quest_accept_denied`** (optional comment).
- `server/README.md` subsection; module doc note.
**Out of scope**
- E9.M1 ingest pipeline; runtime telemetry emit.
**Acceptance criteria**
- [ ] Hook sites exist at accept, step complete, quest complete paths.
- [ ] Epic 7 Slice 1 telemetry vocabulary documented.
**Client counterpart:** none (infrastructure-only).
---
### E7M1-11 — Client quest progress + accept HUD (Godot)
**Goal:** Godot **`GET …/quest-progress`** client + readable quest panel; accept via **`POST …/accept`**.
**In scope**
- `quest_progress_client.gd`, optional `quest_definitions_client.gd` for display names.
- HUD: **`QuestProgressLabel`** (active/completed summary), **`QuestAcceptFeedbackLabel`** (deny/success copy).
- Boot hydrate + refresh after gather/craft/cast/encounter events (mirror **`encounter_progress_client.gd`** pattern).
- Prototype accept bindings: e.g. keys **Q** / **Shift+Q** for next eligible quest accept (document in manual QA).
- GdUnit: HTTP double, parse happy path + deny path.
- `client/README.md` section.
- `docs/manual-qa/NEO-122.md`.
**Out of scope**
- Quest journal art, map pins, NPC dialog; final quest picker UI.
**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).
**Client counterpart:** this story **is** the client counterpart for E7M1-08/09.
---
### E7M1-12 — Playable onboarding quest chain capstone (Godot)
**Goal:** Prove Epic 7 Slice 1 acceptance **in Godot**: complete all four prototype quests (gather → refine → combat → operator chain) without Bruno; satisfies vision **quest loop** gate.
**In scope**
- **`docs/manual-qa/NEO-123.md`**: numbered single-session capstone — fresh server restart, accept onboarding quests in order, perform gather/craft/combat actions, verify chain quest **`prototype_quest_operator_chain`** completes when **`contract_handoff_token`** held.
- **`client/README.md`**: **End-to-end onboarding quest loop** section — flow table; cross-links NEO-112NEO-122.
- Module alignment on completion: [E7_M1](../decomposition/modules/E7_M1_QuestStateMachine.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**
- E7.M2 reward bundles; faction reputation; quest VFX; Bruno-only verification as prototype-complete proof.
**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.
**Client counterpart:** this story **is** the Slice 1 client capstone.
---
## Decomposition complete checklist
- [x] Every player-visible AC has a **`client`** Linear issue (NEO-122, NEO-123)
- [x] No forbidden deferral language in backlog
- [x] Capstone client story exists (E7M1-12 / NEO-123)
- [x] Epic DoD updated for Godot verification
- [x] [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E7.M1 row updated
- [x] [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) E7.M1 note updated
## Related docs
- [epic_07_quest_faction.md](../decomposition/epics/epic_07_quest_faction.md)
- [quest_scope_and_party.md](../decomposition/modules/quest_scope_and_party.md)
- [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) (encounter token precursor)
- [E3S5-client-prototype-backlog.md](E3S5-client-prototype-backlog.md) (gather/craft client HUD)
- [neon_sprawl_vision.plan.md — Prototype Scope](../../neon_sprawl_vision.plan.md)

View File

@ -0,0 +1,122 @@
# NEO-103 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-103 |
| **Title** | E5M3-04: GET /game/world/encounter-definitions + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-103/e5m3-04-get-gameworldencounter-definitions-bruno |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-04** |
| **Branch** | `NEO-103-get-world-encounter-definitions` |
| **Precursor** | [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) — injectable encounter + reward-table registries + DI (**landed on `main`**) |
| **Pattern** | [NEO-78](NEO-78-implementation-plan.md) — world definition GET + Bruno; [NEO-60](NEO-60-implementation-plan.md) — nested child projection via second registry |
| **Blocks** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) — encounter progress + completion stores |
| **Client counterpart** | None this story — player-visible labels start at [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) / [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) via this HTTP route |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Reward table in response** | Nested summary vs linked `rewardTableId` only? | **Nested `rewardTable`** (`id`, `displayName`, `fixedGrants[]`) — backlog goal + one round-trip for NEO-110 loot preview; inject **`IRewardTableDefinitionRegistry`**. | **Adopted** — nested summary |
| **Completion criteria JSON shape** | Nested `completionCriteria: { kind }` vs flat `completionCriteriaKind`? | **Nested object** — matches `encounter-def.schema.json` and catalog JSON. | **Adopted** — nested object |
| **Manual QA doc** | Add `docs/manual-qa/NEO-103.md`? | **Skip** — server-only (NEO-78 precedent); Bruno + automated API tests suffice. | **Adopted** — skip |
## Goal, scope, and out-of-scope
**Goal:** Expose a stable, read-only JSON endpoint that returns the frozen prototype encounter definition(s) for Bruno, tooling, and optional client preview ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)) — backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** without duplicating catalog truth.
**In scope (from Linear + [E5M3-04](E5M3-prototype-backlog.md#e5m3-04--get-gameworldencounter-definitions)):**
- **`GET /game/world/encounter-definitions`** with versioned envelope (`schemaVersion` **1**, **`encounters`** array).
- Encounter row fields: **`id`**, **`displayName`**, **`completionCriteria`** (`{ kind }`), **`requiredNpcInstanceIds`**, nested **`rewardTable`** summary (`id`, `displayName`, `fixedGrants[]` with `itemId` + `quantity`).
- Encounters ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
- Bruno folder `bruno/neon-sprawl-server/encounter-definitions/`.
- `server/README.md` route section.
- API integration tests (AAA).
**Out of scope (from Linear + backlog):**
- Per-player progress, completion grants, combat hooks (E5M3-05+).
- Separate **`GET /game/world/reward-table-definitions`** route (not in backlog; nested summary covers prototype).
- Godot / client wiring ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)).
- `docs/manual-qa/NEO-103.md` (server-only per kickoff).
## Acceptance criteria checklist
- [x] GET returns frozen encounter in ascending **`id`** order with **`schemaVersion`** **1**.
- [x] Bruno happy GET passes in CI Bruno step.
## Implementation reconciliation (shipped)
- **Route:** `GET /game/world/encounter-definitions` — injects `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry`.
- **DTOs:** `EncounterDefinitionsListResponse` (schema v1, `encounters[]`); nested `completionCriteria: { kind }`; nested `rewardTable` with `fixedGrants[]`.
- **Wiring:** `MapEncounterDefinitionsWorldApi()` in `Program.cs` after resource-node definitions.
- **Tests:** AAA integration test in `EncounterDefinitionsWorldApiTests`.
- **Bruno:** `bruno/neon-sprawl-server/encounter-definitions/`.
- **Docs:** `server/README.md` section; E5.M3 module snapshot + alignment register + backlog updated.
## Technical approach
1. **Route:** **`GET /game/world/encounter-definitions`** — parameterless read; inject **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** only (not catalog types).
2. **Response shape:** Top-level **`schemaVersion`** (`EncounterDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`encounters`** array. Each row maps from **`EncounterDefRow`**:
- **`id`**, **`displayName`**
- **`completionCriteria`**: `{ "kind": "<CompletionCriteriaKind>" }` (nested, matching content schema)
- **`requiredNpcInstanceIds`**: string array (preserve catalog order)
- **`rewardTable`**: resolve via **`rewardTableRegistry.TryGetDefinition(encounter.RewardTableId, …)`**; on miss throw **`InvalidOperationException`** (fail-fast — catalog cross-ref validated at load; same pattern as [`ResourceNodeDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefinitionsWorldApi.cs) missing yield). Nested object: **`id`**, **`displayName`**, **`fixedGrants`** (`itemId`, `quantity`).
3. **Ordering:** Build list from **`encounterRegistry.GetDefinitionsInIdOrder()`**. Prototype freeze: single id **`prototype_combat_pocket`**.
4. **Implementation:** New **`EncounterDefinitionsWorldApi`** + **`EncounterDefinitionsListDtos.cs`** in `Game/Encounters/` (mirror [`AbilityDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs)). Wire **`app.MapEncounterDefinitionsWorldApi()`** from **`Program.cs`** after other world definition APIs (near **`MapResourceNodeDefinitionsWorldApi()`**).
5. **Bruno:** `bruno/neon-sprawl-server/encounter-definitions/` with **`Get encounter definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`encounters.length === 1`**, id **`prototype_combat_pocket`**, nested **`completionCriteria.kind`** **`defeat_all_targets`**, three NPC ids, nested **`rewardTable`** with frozen grants.
6. **Docs:** Add **`server/README.md`** section (GET + curl). Update [E5_M3](E5_M3_EncounterAndRewardTables.md) **Related implementation slices** / implementation snapshot and [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M3 row when landed.
### Expected prototype row values (from content)
| Field | Value |
|-------|--------|
| **`id`** | `prototype_combat_pocket` |
| **`displayName`** | Prototype Combat Pocket |
| **`completionCriteria.kind`** | `defeat_all_targets` |
| **`requiredNpcInstanceIds`** | `prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite` |
| **`rewardTable.id`** | `prototype_combat_pocket_clear` |
| **`rewardTable.displayName`** | Prototype Combat Pocket Clear |
| **`rewardTable.fixedGrants`** | `scrap_metal_bulk` ×10, `contract_handoff_token` ×1 |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps both registries → JSON. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionsListDtos.cs` | Versioned response + row/nested reward DTOs for JSON serialization. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, frozen encounter + nested reward table. |
| `bruno/neon-sprawl-server/encounter-definitions/Get encounter definitions.bru` | Manual / CI Bruno verification against dev server. |
| `bruno/neon-sprawl-server/encounter-definitions/folder.bru` | Bruno folder metadata. |
| `docs/plans/NEO-103-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `MapEncounterDefinitionsWorldApi()` alongside other game world definition APIs. |
| `server/README.md` | Document `GET /game/world/encounter-definitions`, curl example, registry injection note. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — HTTP read model bullet (NEO-103). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-103 HTTP projection when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-04 acceptance criteria landed when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionsWorldApiTests.cs` | **Arrange:** `InMemoryWebApplicationFactory`. **Act:** `GET /game/world/encounter-definitions`. **Assert:** 200 OK; `schemaVersion` 1; exactly one encounter; id `prototype_combat_pocket`; `displayName` Prototype Combat Pocket; `completionCriteria.kind` `defeat_all_targets`; `requiredNpcInstanceIds` contains three frozen NPC ids; nested `rewardTable.id` `prototype_combat_pocket_clear`; `fixedGrants` quantities 10 + 1 for `scrap_metal_bulk` and `contract_handoff_token`. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Missing reward table at HTTP time** | Throw **`InvalidOperationException`** — load-time cross-ref should guarantee presence; mirrors resource-node yield guard. | **adopted** |
| **Separate reward-table GET later** | Defer — nested summary satisfies prototype; add dedicated route only if a future story needs reward tables without encounters. | **deferred** |
| **NPC id order in JSON** | Preserve catalog/`EncounterDefRow` order (matches `prototype_encounters.json`); do not re-sort. | **adopted** |

View File

@ -0,0 +1,155 @@
# NEO-104 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-104 |
| **Title** | E5M3-05: Encounter progress + completion stores |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-104/e5m3-05-encounter-progress-completion-stores |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-05** |
| **Branch** | `NEO-104-encounter-progress-completion-stores` |
| **Precursor** | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) — `GET /game/world/encounter-definitions` (**landed on `main`**) |
| **Pattern** | [NEO-61](NEO-61-implementation-plan.md) / `InMemoryResourceNodeInstanceStore` — keyed in-memory store + static operations; [NEO-44](NEO-44-implementation-plan.md) — gig progression store DI |
| **Blocks** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompletionOperations` + inventory reward apply |
| **Client counterpart** | None this story — player-visible encounter HUD starts at [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) `GET /game/players/{id}/encounter-progress` |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Storage backend** | In-memory only vs Postgres when configured? | **In-memory only** — E5M3 kickoff default; session prototype. | **Adopted** — in-memory only |
| **Progress vs completion** | Single store vs split? | **Split stores** — progress: `started` + `defeatedNpcInstanceIds`; completion: idempotent `completedAt`; operations expose `IsAllRequiredTargetsDefeated`. | **Adopted** — split stores |
| **Encounter binding** | Explicit `encounterId` vs auto-resolve from NPC? | **Auto-resolve** — scan `IEncounterDefinitionRegistry` for encounter whose `requiredNpcInstanceIds` contains the NPC. | **Adopted** — auto-resolve from `npcInstanceId` |
| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — E5M3-07 / E5M3-08; NEO-104 ships stores + operations + unit tests only. | **Adopted** (implicit — not asked; backlog out-of-scope) |
| **Activation before defeat** | Auto-activate on `TryMarkTargetDefeated` vs require prior `TryActivateOnFirstEngagement`? | **Require activation**`TryMarkTargetDefeated` no-ops when not started; activation is separate (NEO-106 wires first damaging cast). | **Adopted** (plan default; backlog activation hook) |
## Goal, scope, and out-of-scope
**Goal:** Server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer — plus **`EncounterProgressOperations`** that resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**.
**In scope (from Linear + [E5M3-05](E5M3-prototype-backlog.md#e5m3-05--encounter-progress--completion-stores)):**
- **`IEncounterProgressStore`** — keyed by **`(playerId, encounterId)`**; fields: **`started`** (bool), **`defeatedNpcInstanceIds`** (set, normalized lowercase).
- **`IEncounterCompletionStore`** — **`TryMarkCompleted`**, **`IsCompleted`**, **`TryGetCompletedAt`**; second **`TryMarkCompleted`** is no-op (`false`).
- **`InMemoryEncounterProgressStore`**, **`InMemoryEncounterCompletionStore`** — thread-safe `ConcurrentDictionary` + per-key locks (mirror gathering/NPC stores).
- **`EncounterProgressOperations`**:
- **`TryActivateOnFirstEngagement(playerId, npcInstanceId, …)`** — resolve encounter; set `started` if not started and not completed.
- **`TryMarkTargetDefeated(playerId, npcInstanceId, …)`** — requires started + not completed; add NPC to defeated set if in encounters required list; idempotent re-mark of same NPC returns success without duplicate progress.
- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — compares defeated set to registry `requiredNpcInstanceIds` (set equality).
- DI registration in **`EncounterCatalogServiceCollectionExtensions`** or new **`EncounterProgressServiceCollectionExtensions`**.
- **`InMemoryWebApplicationFactory`** — register in-memory encounter stores (remove any Postgres placeholder if added later).
- Unit tests (AAA) with injected fake stores / registry.
- `server/README.md` encounter progress section (brief).
**Out of scope (from Linear + backlog):**
- Inventory grants, **`EncounterCompleteEvent`**, **`EncounterCompletionOperations`** ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)).
- Combat defeat / ability-cast wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)).
- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- Postgres persistence, Godot, Bruno, `docs/manual-qa/NEO-104.md`.
## Acceptance criteria checklist
- [x] Defeating required NPCs accumulates progress in any order (via `TryMarkTargetDefeated` after activation).
- [x] Completion predicate true only when all required ids defeated (`IsAllRequiredTargetsDefeated`).
- [x] Second completion attempt on same player+encounter is idempotent no-op at store layer (`TryMarkCompleted` returns `false`).
## Implementation reconciliation (shipped)
- **Stores:** `IEncounterProgressStore` / `InMemoryEncounterProgressStore`; `IEncounterCompletionStore` / `InMemoryEncounterCompletionStore`; `EncounterProgressIds` normalization.
- **Operations:** `EncounterProgressOperations``TryResolveEncounterForNpc`, `TryActivateOnFirstEngagement`, `TryMarkTargetDefeated`, `IsAllRequiredTargetsDefeated`.
- **DI:** registered in `AddEncounterAndRewardCatalogs`.
- **Tests:** `InMemoryEncounterProgressStoreTests`, `InMemoryEncounterCompletionStoreTests`, `EncounterProgressOperationsTests`; host DI resolve in `EncounterDefinitionRegistryTests`.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
- **Plan file list:** `Program.cs` and `InMemoryWebApplicationFactory.cs` unchanged — stores register in existing `AddEncounterAndRewardCatalogs` (already invoked at startup); always in-memory (no Postgres test-factory swap).
## Technical approach
1. **Snapshots / keys**
- **`EncounterProgressSnapshot`**: `PlayerId`, `EncounterId`, `Started`, `DefeatedNpcInstanceIds` (read-only set).
- **`EncounterPlayerEncounterKey`**: normalize `playerId` + `encounterId` with trim + lowercase (match registry / position-state id normalization).
- NPC ids normalized the same way when stored and compared.
2. **`IEncounterProgressStore`**
- `TryGetProgress(playerId, encounterId, out snapshot)` — missing row ⇒ not started, empty defeated set.
- `TryActivate(playerId, encounterId)` — creates row with `started: true`, empty defeated set; returns `true` on first activation, `false` if already started (idempotent) or if completion store says completed.
- `TryAddDefeatedTarget(playerId, encounterId, npcInstanceId)` — requires existing started row; add to set; return `true` if newly added, `false` if duplicate NPC or not started.
3. **`IEncounterCompletionStore`**
- `bool IsCompleted(playerId, encounterId)`
- `bool TryMarkCompleted(playerId, encounterId, DateTimeOffset completedAt)``true` first time, `false` if already completed.
- `bool TryGetCompletedAt(…, out DateTimeOffset completedAt)`
4. **`EncounterProgressOperations`** (static, mirror `GatherOperations` / `AggroOperations`)
- **`TryResolveEncounterForNpc(npcInstanceId, registry, out encounterId, out definition)`** — scan `GetDefinitionsInIdOrder()` for first encounter where `requiredNpcInstanceIds` contains normalized NPC (prototype: single `prototype_combat_pocket`).
- **`TryActivateOnFirstEngagement`**: resolve encounter → check completion store → `TryActivate` on progress store.
- **`TryMarkTargetDefeated`**: resolve encounter → deny if completed → deny if not started → validate NPC in required list → `TryAddDefeatedTarget`.
- **`IsAllRequiredTargetsDefeated`**: load progress + definition; compare required set to defeated set (order-independent).
- Inject **`TimeProvider`** only where completion store needs timestamps (completion store accepts `completedAt` from caller; NEO-105 passes `TimeProvider.GetUtcNow()`).
5. **DI**
- `services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();`
- `services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();`
- Extend **`AddEncounterAndRewardCatalogs`** or add **`AddEncounterProgressStores`** called from **`Program.cs`** after encounter catalogs.
6. **Reserved hook**
- XML comment on **`TryActivateOnFirstEngagement`**: `// TODO(E9.M1): encounter_start telemetry` (activates E5M3-07 hook site).
7. **Docs**
- `server/README.md` — stores + operations, note combat/HTTP deferred.
- Update [E5_M3](E5_M3_EncounterAndRewardTables.md) implementation snapshot when landed.
### Prototype encounter behavior (frozen)
| Step | Expected |
|------|----------|
| Activate | `TryActivateOnFirstEngagement(devPlayer, prototype_npc_melee)` → started, 0/3 defeated |
| Defeat 2 | Mark melee + ranged → `IsAllRequiredTargetsDefeated` false |
| Defeat 3 | Mark elite → `IsAllRequiredTargetsDefeated` true |
| Complete store | `TryMarkCompleted` once → true; second → false |
| Order | elite → melee → ranged also reaches all-defeated |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressSnapshot.cs` | Immutable progress row for store read model. |
| `server/NeonSprawl.Server/Game/Encounters/IEncounterProgressStore.cs` | Progress store contract. |
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterProgressStore.cs` | Thread-safe in-memory implementation. |
| `server/NeonSprawl.Server/Game/Encounters/IEncounterCompletionStore.cs` | Completion store contract. |
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompletionStore.cs` | Thread-safe in-memory implementation. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs` | Static orchestration: resolve encounter, activate, mark defeat, all-defeated predicate. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressOperationsTests.cs` | AAA unit tests: activation, any-order defeats, all-defeated predicate, completion idempotency. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterProgressStoreTests.cs` | AAA store tests: activate idempotency, duplicate defeat, not-started deny. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompletionStoreTests.cs` | AAA completion idempotency tests. |
| `docs/plans/NEO-104-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register `IEncounterProgressStore` + `IEncounterCompletionStore` singletons (or call new extension from here). |
| `server/NeonSprawl.Server/Program.cs` | Ensure encounter progress stores registered at startup (if split extension, invoke it). |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Swap in test in-memory encounter stores; strip production registrations in test host. |
| `server/README.md` | Document encounter progress/completion stores and operations; defer HTTP/combat to NEO-106/108. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — progress/completion stores (NEO-104). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-104 stores when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-05 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `InMemoryEncounterProgressStoreTests.cs` | **Arrange:** store. **Act/Assert:** first `TryActivate` true; second false; `TryAddDefeatedTarget` without activate false; duplicate NPC add false; two distinct NPCs in set. |
| `InMemoryEncounterCompletionStoreTests.cs` | **Arrange:** store + fixed `completedAt`. **Act:** `TryMarkCompleted` twice. **Assert:** first true, second false; `IsCompleted` true; `TryGetCompletedAt` matches. |
| `EncounterProgressOperationsTests.cs` | **Arrange:** real `EncounterDefinitionRegistry` from test factory catalog + in-memory stores. **Act/Assert:** activate on `prototype_npc_melee`; defeat three NPCs in permuted order → `IsAllRequiredTargetsDefeated` true only after third; partial progress false; `TryMarkTargetDefeated` before activate false; `TryMarkCompleted` idempotent; progress blocked when already completed. |
| `EncounterDefinitionRegistryTests.cs` (optional host test) | **Arrange:** `InMemoryWebApplicationFactory`. **Assert:** resolves `IEncounterProgressStore` + `IEncounterCompletionStore` from DI. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Multiple encounters sharing one NPC** | First match in `GetDefinitionsInIdOrder()` wins — acceptable for prototype (single encounter). Document in operations XML. | **adopted** |
| **Mark defeat after encounter completed** | `TryMarkTargetDefeated` returns false — fail closed. | **adopted** |
| **Postgres follow-on** | Defer — new migration + store pair in a future issue when session persistence is required. | **deferred** |

View File

@ -0,0 +1,131 @@
# NEO-105 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-105 |
| **Title** | E5M3-06: EncounterCompletionOperations + inventory reward apply |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-105/e5m3-06-encountercompletionoperations-inventory-reward-apply |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-06** |
| **Branch** | `NEO-105-encounter-completion-inventory-reward-apply` |
| **Precursor** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) — `IEncounterProgressStore` + `IEncounterCompletionStore` + `EncounterProgressOperations` (**landed on `main`**) |
| **Pattern** | [NEO-62](NEO-62-implementation-plan.md) / `GatherOperations` — static ops + reason codes + grant summary; [NEO-69](NEO-69-implementation-plan.md) / `CraftOperations` — simulate-all pre-flight + compensating rollback on partial apply failure |
| **Blocks** | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) — combat defeat → progress/completion wiring; [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) — `EncounterCompleteEvent` persistence + E7.M2 hook stub |
| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels (via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) HTTP read) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **EncounterCompleteEvent scope** | Define event type on NEO-105 vs defer entirely to NEO-107? | **Define `EncounterCompleteEvent` + return in `EncounterCompletionResult` on success** — E5M3-06 goal calls for internal completion payload; **NEO-107** persists the record and adds E7.M2 hook stub (E5M3-09). | **Adopted** — result payload only; no persistence |
| **Grant transaction** | Pre-flight vs apply-then-hope? | **Simulate all `fixedGrants` on inventory snapshot, then apply sequentially with compensating rollback** — mirror `CraftOperations`; never `TryMarkCompleted` unless every grant applied. | **Adopted** (repo precedent; Linear AC “inventory deny fails closed”) |
| **Completion gate** | Who checks all-defeated? | **`TryCompleteAndGrant` requires `EncounterProgressOperations.IsAllRequiredTargetsDefeated`** + not already completed — NEO-106 calls after last defeat mark. | **Adopted** (backlog + NEO-104 split) |
| **Combat / HTTP wiring** | Wire `AbilityCastApi` or HTTP on this story? | **Defer** — NEO-106 / NEO-108; NEO-105 ships operations + unit tests only. | **Adopted** (backlog out-of-scope) |
| **Bruno** | Add encounter-completion Bruno folder? | **Defer to NEO-108** — no player HTTP route yet; backlog marks Bruno optional here. | **Adopted** (backlog) |
## Goal, scope, and out-of-scope
**Goal:** `EncounterCompletionOperations.TryCompleteAndGrant` applies **`RewardTable.fixedGrants`** via **`PlayerInventoryOperations`** exactly once per player+encounter, marks the completion store only when all grants succeed, and returns an internal **`EncounterCompleteEvent`** payload for downstream routers (persistence deferred).
**In scope (from Linear + [E5M3-06](E5M3-prototype-backlog.md#e5m3-06--encountercompletionoperations--inventory-reward-apply)):**
- **`EncounterCompletionOperations.TryCompleteAndGrant`** — resolve encounter → reward table → pre-flight simulate → apply fixed grants → `TryMarkCompleted` with `TimeProvider.GetUtcNow()`.
- **`EncounterCompletionResult`**, **`EncounterCompletionReasonCodes`**, **`EncounterGrantApplied`**, **`EncounterCompleteEvent`** (internal record; not persisted).
- Structured deny codes: `already_completed`, `not_ready`, `unknown_encounter`, `unknown_reward_table`, `inventory_full`, `inventory_store_missing`, passthrough `invalid_item` / `invalid_quantity`.
- Compensating inventory rollback when a later grant fails after earlier grants applied, or when `TryMarkCompleted` returns false after grants (race/idempotent replay).
- Unit tests (AAA) with `InMemoryWebApplicationFactory`: first completion grants frozen prototype loot once; second completion no duplicate; bag-full deny without completion flag.
- `server/README.md` encounter completion section (brief).
**Out of scope (from Linear + backlog):**
- Combat defeat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)).
- **`EncounterCompleteEvent` store / E7.M2 quest-credit hook** ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)).
- HTTP **`GET /game/players/{id}/encounter-progress`** ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Postgres persistence, Bruno, `docs/manual-qa/NEO-105.md`.
## Acceptance criteria checklist
- [x] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
- [x] Second completion does not duplicate grants.
- [x] Inventory deny fails closed without marking complete.
## Implementation reconciliation (shipped)
- **Operations:** `EncounterCompletionOperations.TryCompleteAndGrant` — pre-flight simulate, sequential grant apply with compensating rollback, idempotent `TryMarkCompleted`.
- **Types:** `EncounterCompletionResult`, `EncounterCompletionReasonCodes`, `EncounterGrantApplied`, `EncounterCompleteEvent` (result payload only; NEO-107 persists).
- **Tests:** `EncounterCompletionOperationsTests` — grant once, `already_completed` replay, `not_ready`, `inventory_full` fail-closed, apply-phase rollback (`InventoryStoreFullAfterFirstGrant`), mark-fail rollback.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
## Technical approach
1. **`EncounterCompletionReasonCodes`**
- Domain-specific: `already_completed`, `not_ready`, `unknown_encounter`, `unknown_reward_table`, `inventory_store_missing`.
- Passthrough: `inventory_full`, `invalid_item`, `invalid_quantity` from **`PlayerInventoryReasonCodes`**.
2. **`EncounterGrantApplied`** — readonly record `(ItemId, Quantity)` mirroring **`GatherGrantApplied`**.
3. **`EncounterCompleteEvent`** — readonly record: `PlayerId`, `EncounterId`, `RewardTableId`, `IReadOnlyList<EncounterGrantApplied> GrantedItems`, `DateTimeOffset CompletedAt`, `IdempotencyKey` (stable string e.g. `{playerId}:{encounterId}` normalized). Returned on success only; **NEO-107** will persist/emit.
4. **`EncounterCompletionResult`** — `(Success, ReasonCode?, GrantsApplied, EncounterCompleteEvent? CompleteEvent, DateTimeOffset? CompletedAt)`.
5. **`EncounterCompletionOperations.TryCompleteAndGrant`**
- Normalize **`encounterId`** via **`IEncounterDefinitionRegistry.TryNormalizeKnown`**; load **`EncounterDefRow`**.
- Early deny **`already_completed`** if **`IEncounterCompletionStore.IsCompleted`**.
- Early deny **`not_ready`** if **`!EncounterProgressOperations.IsAllRequiredTargetsDefeated`**.
- Resolve **`rewardTableId`** → **`IRewardTableDefinitionRegistry.TryGetDefinition`**; deny **`unknown_reward_table`** if missing.
- Load **`FixedGrants`** (prototype: scrap ×10, token ×1).
- **Pre-flight:** clone inventory snapshot; **`PlayerInventoryOperations.TrySimulateAddStack`** for each grant in catalog order.
- **Apply:** foreach grant **`TryAddStack`**; on deny/store-missing, **`CompensatingRemoveGrants`** for prior applied rows and return deny **without** marking complete.
- **`TryMarkCompleted(playerId, encounterId, timeProvider.GetUtcNow())`**; if `false`, compensating remove all grants and return **`already_completed`**.
- Success: build **`EncounterCompleteEvent`**, return grants + event + `CompletedAt`.
- Comment-only hook site: `// TODO(E7.M2): quest-credit router consumes EncounterCompleteEvent` (NEO-107 expands).
6. **No new DI registration** — static operations like **`GatherOperations`**; callers inject stores/registries from existing **`AddEncounterAndRewardCatalogs`** + inventory DI.
### Prototype completion flow (frozen)
| Step | Expected |
|------|----------|
| Setup | Activate + defeat all three NPCs → `IsAllRequiredTargetsDefeated` true |
| First grant | `TryCompleteAndGrant` → success; bag +10 scrap, +1 token; completion store marked |
| Replay | Second `TryCompleteAndGrant` → deny `already_completed`; inventory unchanged |
| Bag full | Fill bag slots → `TryCompleteAndGrant` → deny `inventory_full`; `IsCompleted` false |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionReasonCodes.cs` | Stable deny reason constants. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterGrantApplied.cs` | Successful grant row in result/event. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs` | Internal completion payload (returned, not persisted on NEO-105). |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs` | Server-internal completion resolution envelope. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | `TryCompleteAndGrant` orchestration. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | AAA unit tests for grant-once, idempotent deny, inventory-full fail-closed. |
| `docs/plans/NEO-105-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/README.md` | Document `EncounterCompletionOperations`, reason codes, and deferrals to NEO-106/107/108. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — completion + inventory grant ops (NEO-105). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-105 completion grants when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-105 completion ops landed (when shipped). |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-06 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `EncounterCompletionOperationsTests.cs` | **Arrange:** `InMemoryWebApplicationFactory` + activate/defeat-all helper for `prototype_combat_pocket`. **Act/Assert:** first `TryCompleteAndGrant` success with scrap ×10 + token ×1 in inventory and `CompleteEvent` populated; second call `already_completed` with unchanged quantities; `not_ready` when only 2/3 defeated; bag-full deny `inventory_full` with `IsCompleted` false and zero grant delta; apply-phase rollback via `InventoryStoreFullAfterFirstGrant` when second grant fails after first succeeds; mark-fail rollback when `TryMarkCompleted` returns false. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Grant order** | Apply in **`FixedGrants` catalog order** (scrap then token); rollback in reverse on failure. | **adopted** |
| **Mark-complete race** | If `TryMarkCompleted` false after grants, remove grants and return `already_completed` — treat as lost race to another completer. | **adopted** |
| **Event persistence** | Deferred to **NEO-107** — NEO-105 only returns payload in result. | **adopted** |
| **Postgres follow-on** | Defer — completion store Postgres migration is a future session-persistence issue. | **deferred** |

View File

@ -0,0 +1,141 @@
# NEO-106 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-106 |
| **Title** | E5M3-07: Wire combat defeat → encounter progress/completion |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-106/e5m3-07-wire-combat-defeat-encounter-progresscompletion |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-07** |
| **Branch** | `NEO-106-wire-combat-defeat-encounter-progress-completion` |
| **Precursor** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompletionOperations.TryCompleteAndGrant` + inventory grants (**landed on `main`**) |
| **Pattern** | [NEO-44](NEO-44-implementation-plan.md) / `CombatDefeatGigXpGrant` — static wiring helper called from `AbilityCastApi`; [NEO-104](NEO-104-implementation-plan.md) / [NEO-105](NEO-105-implementation-plan.md) — encounter ops already exist |
| **Blocks** | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) — `GET /game/players/{id}/encounter-progress` + Bruno; [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) — telemetry hook sites (activates `encounter_start` comment from NEO-104) |
| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — encounter progress + loot feedback labels (via NEO-108 HTTP read) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Wiring structure** | Inline in `AbilityCastApi` vs dedicated helper? | **`EncounterCombatWiring` static helper** — mirror `CombatDefeatGigXpGrant`; keeps cast route readable; unit-testable without HTTP. | **Adopted** |
| **Activation trigger** | First damaging cast vs any successful accept? | **First cast with `DamageDealt > 0`** against an encounter NPC — E5M3 kickoff default (“first damaging cast”); colocate with existing aggro block. | **Adopted** (backlog + NEO-104 precedent; not separately asked) |
| **Integration tests** | `AbilityCastApiTests` in-proc vs Bruno defeat-all-three now? | **Extend `AbilityCastApiTests` only** — existing defeat spine helpers + NEO-44 gig XP test precedent; Bruno deferred to NEO-108 per backlog. | **Adopted** |
| **Cast response DTO** | Expose encounter completion on `AbilityCastResponse`? | **No** — NEO-108 adds authoritative GET; cast response unchanged (inventory + stores verified in tests). | **Adopted** (backlog out-of-scope; not separately asked) |
| **Completion on inventory deny** | Third defeat when bag full? | **Defeat progress recorded; `TryCompleteAndGrant` fail-closed per NEO-105** — no completion mark, no grants; player can retry when bag has space (future HTTP will reflect `active` + all-defeated). | **Adopted** (NEO-105 contract; not separately asked) |
## Goal, scope, and out-of-scope
**Goal:** Hook the **`POST …/ability-cast`** accept path so NPC defeats update encounter progress and invoke **`EncounterCompletionOperations.TryCompleteAndGrant`** when all required targets are defeated — preserving **NEO-44** per-defeat gig XP.
**In scope (from Linear + [E5M3-07](E5M3-prototype-backlog.md#e5m3-07--wire-combat-defeat--encounter-progresscompletion)):**
- **`EncounterCombatWiring.TryProcessCastOutcome`** — on damaging hit (`DamageDealt > 0`): **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; on lethal hit (`TargetDefeated`): **`TryMarkTargetDefeated`**, then when **`IsAllRequiredTargetsDefeated`**: **`EncounterCompletionOperations.TryCompleteAndGrant`** for resolved encounter id.
- Wire helper from **`AbilityCastApi`** after combat resolve (alongside existing aggro + **`CombatDefeatGigXpGrant`** on defeat).
- Inject encounter + inventory dependencies into cast route handler (registries, progress/completion stores, item registry, inventory store, `TimeProvider`).
- **`EncounterCombatWiringTests`** — AAA unit tests for activate-on-damage, mark-on-defeat, completion-on-third-defeat (mocked deps / factory services).
- **`AbilityCastApiTests`** — in-proc integration: defeat all three prototype NPCs via cast → inventory grants once; partial progress (12 defeats) → no loot; gig XP **25** per defeat preserved (**75** total after three defeats).
- `server/README.md` — document combat → encounter wiring; update NEO-104/105 deferral notes.
**Out of scope (from Linear + backlog):**
- **`GET /game/players/{id}/encounter-progress`** + Bruno ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- **`EncounterCompleteEvent` persistence** + E7.M2 hook ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)).
- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Cast response schema changes, Postgres, `docs/manual-qa/NEO-106.md`.
## Acceptance criteria checklist
- [x] Defeating third required NPC triggers exactly one completion grant (`scrap_metal_bulk` ×10, `contract_handoff_token` ×1).
- [x] Partial progress (12 defeats) does not grant loot.
- [x] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`** (25 per defeat, unchanged skill progression).
## Implementation reconciliation (shipped)
- **Wiring:** `EncounterCombatWiring.TryProcessCastOutcome` — activate on `damageDealt > 0`, mark on `targetDefeated`, `TryCompleteAndGrant` when all required NPCs defeated.
- **Integration:** `AbilityCastApi` injects encounter/inventory deps; call order preserves NEO-44 gig XP before encounter completion.
- **Tests:** `EncounterCombatWiringTests` (8); `AbilityCastApiTests` — defeat-all-three loot once, two-defeat no loot, 75 gig XP after three defeats.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog updated.
## Technical approach
1. **`EncounterCombatWiring`** (`Game/Encounters/`)
- **`TryProcessCastOutcome(playerId, targetNpcInstanceId, damageDealt, targetDefeated, …deps)`** — best-effort (no throw); outcome ignored on cast accept path (mirror gig XP grant).
- **Activation:** when `damageDealt > 0`, call **`TryActivateOnFirstEngagement(playerId, targetNpcInstanceId, …)`**.
- **Defeat mark:** when `targetDefeated`, call **`TryMarkTargetDefeated(…)`**; resolve **`encounterId`** via **`TryResolveEncounterForNpc`**.
- **Completion:** after mark, if **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`**, call **`TryCompleteAndGrant(playerId, encounterId, …)`** — success/failure does not change cast accept JSON.
- Optional debug log on completion deny (inventory full, etc.) — same pattern as **`CombatDefeatGigXpGrant`**.
2. **`AbilityCastApi` changes**
- Add DI parameters: **`IEncounterDefinitionRegistry`**, **`IRewardTableDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IItemDefinitionRegistry`**, **`IPlayerInventoryStore`**, **`TimeProvider`** (reuse route `clock` for completion timestamp).
- After successful combat resolve:
- Existing **`DamageDealt > 0`** aggro block: also call wiring with activation context (or call wiring once per accept with both flags).
- Existing **`TargetDefeated`** block (after **`CombatDefeatGigXpGrant`**): call wiring with defeat context — **gig XP grant order preserved before encounter completion** so NEO-44 AC holds even if completion fails.
- Update XML remarks: NEO-106 encounter wiring.
3. **Call order on lethal accept (frozen)**
| Step | Action |
|------|--------|
| 1 | `CombatOperations.TryResolve` success |
| 2 | Aggro (if `DamageDealt > 0`) |
| 3 | Cooldown start |
| 4 | `NpcRuntimeOperations.TryStopOnTargetDefeat` (if defeated) |
| 5 | **`CombatDefeatGigXpGrant.GrantOnCombatDefeat`** (if defeated) |
| 6 | **`EncounterCombatWiring.TryProcessCastOutcome`** — activate (if damage > 0) + mark + maybe complete |
| 7 | Return accept JSON |
4. **Integration test helpers** (`AbilityCastApiTests`)
- **`LockPrototypeTargetAsync(client, npcId)`** — generalize existing alpha lock helper.
- **`DefeatLockedTargetWithPulseAsync(client, factory, npcId)`** — bind pulse, lock target, 4 casts with cooldown advance (100 HP / 25 pulse damage).
- **`PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated`** — assert inventory + completion store after third defeat.
- **`PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoDefeated`** — assert zero scrap/token delta.
- Extend or duplicate gig XP test to assert **75** breach XP after three defeats (3 × 25).
5. **No new DI registration** — encounter stores/registries already in **`AddEncounterAndRewardCatalogs`**.
### Prototype cast → encounter flow (frozen)
| Cast event | Encounter effect |
|------------|------------------|
| First pulse hit (damage > 0) vs `prototype_npc_*` | `TryActivateOnFirstEngagement` → progress **started** |
| Lethal hit vs any required NPC | `TryMarkTargetDefeated` → +1 defeated id |
| Third distinct required NPC defeated | `TryCompleteAndGrant` → +10 scrap, +1 token, completion marked |
| Re-hit defeated NPC | Combat deny `target_defeated`; encounter ops no-op |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Static helper: activate on damage, mark on defeat, complete when all defeated. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | AAA unit tests for wiring orchestration (factory-backed deps). |
| `docs/plans/NEO-106-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject encounter/inventory deps; call `EncounterCombatWiring` on accept. |
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | In-proc defeat-all-three integration tests + partial progress + gig XP preservation. |
| `server/README.md` | Document combat → encounter wiring; remove NEO-106 deferral notes from progress/completion sections. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-07 combat wiring (when landed). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-106 wiring when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-106 combat wiring when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-07 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `EncounterCombatWiringTests.cs` | **Arrange:** `InMemoryWebApplicationFactory` services. **Act/Assert:** damaging hit activates encounter; defeat marks progress; third defeat invokes completion (inventory + `IsCompleted`); non-encounter NPC no-op; already-completed encounter skips activate/mark; completion not called when only 2/3 defeated (non-lethal third hit); bag-full third defeat retains all-defeated progress without completion mark. |
| `AbilityCastApiTests.cs` | **Arrange:** HTTP client + pulse bind + per-NPC lock/defeat helpers. **Act/Assert:** defeat all three via cast → `scrap_metal_bulk` ×10 + `contract_handoff_token` ×1 once; two defeats → zero loot; gig XP 75 on breach after three defeats, skill XP unchanged; existing NEO-44 single-defeat test still passes. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Helper vs inline wiring** | **`EncounterCombatWiring`** — adopted at kickoff. | **adopted** |
| **Bruno defeat spine** | Defer to **NEO-108** — in-proc tests sufficient for NEO-106 AC. | **adopted** |
| **NPC HP variance** | All three prototype NPCs use catalog **100 HP**; pulse **25** → 4 casts each — reuse existing defeat loop pattern. | **adopted** |
| **Inventory-full on third defeat** | Progress stays all-defeated; completion retriable when bag has space — document in README; no cast deny. | **adopted** |
| **Event persistence** | Deferred to **NEO-107** — wiring returns/completes via existing `EncounterCompleteEvent` in result only. | **deferred** |

View File

@ -0,0 +1,155 @@
# NEO-107 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-107 |
| **Title** | E5M3-09: EncounterCompleteEvent + E7.M2 quest-credit hook stub |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-107/e5m3-09-encountercompleteevent-e7m2-quest-credit-hook-stub |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-09** |
| **Branch** | `NEO-107-encounter-complete-event-e7m2-hook` |
| **Precursor** | [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) — `EncounterCompleteEvent` result payload + `TryCompleteAndGrant` (**landed on `main`**) · [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) — combat wiring invokes completion (**landed on `main`**) |
| **Pattern** | [NEO-104](NEO-104-implementation-plan.md) — split in-memory stores + per-key locks; [NEO-105](NEO-105-implementation-plan.md) — event type + idempotency key already defined |
| **Blocks** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — client encounter HUD (indirect; needs event record for downstream parity) |
| **Client counterpart** | None this story — server-only event record + hook stub; player-visible HUD is [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) via [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) |
## Kickoff clarifications
**No blocking clarifications needed.** NEO-105 kickoff adopted the split: NEO-105 returns `EncounterCompleteEvent` in `EncounterCompletionResult`; NEO-107 persists the record and expands the E7.M2 hook. E5M3 backlog defaults fix in-memory storage, once-per-player+encounter idempotency, and comment-only E7.M2 behavior (no `QuestRewardBundle` router). NEO-106 wires all completion through `TryCompleteAndGrant` — a single record site preserves atomicity with `TryMarkCompleted`.
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Event store shape** | Dedicated store vs extend `IEncounterCompletionStore`? | **Dedicated `IEncounterCompleteEventStore`** — completion flag is boolean+timestamp; event carries grant summary + idempotency key (NEO-104 split-store precedent). | **Adopted** (backlog + NEO-105) |
| **Record site** | Inside `TryCompleteAndGrant` vs caller after success? | **Inside `TryCompleteAndGrant` after `TryMarkCompleted` succeeds** — same completion commit boundary; NEO-106 / cast path need no duplicate orchestration. | **Adopted** (NEO-105 deferral note) |
| **Storage backend** | In-memory vs Postgres? | **In-memory only** — E5M3 kickoff default (NEO-104). | **Adopted** |
| **E7.M2 runtime** | Stub router call vs comments only? | **Comments only** — document `QuestRewardBundle` / `RewardDeliveryEvent` consumer contract; no quest state machine ([E5M3-09](E5M3-prototype-backlog.md#e5m3-09--encountercompleteevent--e7m2-quest-credit-hook-stub)). | **Adopted** |
| **HTTP / Godot** | Expose event on GET or client? | **Defer** — [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) projects grant summary from stores; this story records only. | **Adopted** (backlog out-of-scope) |
## Goal, scope, and out-of-scope
**Goal:** Persist **`EncounterCompleteEvent`** exactly once per player+encounter when completion grants commit, and expand the **E7.M2** quest-credit hook stub documenting the future **`QuestRewardBundle`** consumer contract.
**In scope (from Linear + [E5M3-09](E5M3-prototype-backlog.md#e5m3-09--encountercompleteevent--e7m2-quest-credit-hook-stub)):**
- **`IEncounterCompleteEventStore`** + **`InMemoryEncounterCompleteEventStore`** — idempotent **`TryRecord(EncounterCompleteEvent)`** keyed by **`(playerId, encounterId)`** / **`IdempotencyKey`**; **`TryGet`** for tests and NEO-108 read path.
- Wire store into **`EncounterCompletionOperations.TryCompleteAndGrant`** — record on success after completion mark; replay / deny paths do not record.
- Expand **`// TODO(E7.M2)`** hook in completion path with **`reward_delivery`** vocabulary and pointer to [E7_M2_RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) **`QuestRewardBundle`** / **`RewardDeliveryEvent`** consumer contract (prototype: quest credit beyond **`contract_handoff_token`** item loot).
- Pass **`IEncounterCompleteEventStore`** through **`EncounterCombatWiring`** → **`AbilityCastApi`** DI chain.
- Unit tests (AAA): first completion records event once; second completion does not duplicate record; deny paths leave store empty.
- `server/README.md` encounter-complete-event section; E5.M3 module snapshot + E7.M2 implementation anchor.
**Out of scope (from Linear + backlog):**
- Production **`QuestRewardBundle`** apply, quest state machine, **`RewardDeliveryEvent`** type implementation ([E7.M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) full router).
- **`GET /game/players/{id}/encounter-progress`** projection ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)).
- Telemetry **`encounter_complete`** ingest ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)).
- Godot HUD — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Postgres persistence, Bruno, `docs/manual-qa/NEO-107.md`.
## Acceptance criteria checklist
- [x] Completion path records **`EncounterCompleteEvent`** exactly once per player+encounter.
- [x] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract (comments + module cross-link).
- [x] No runtime behavior beyond event record + comments.
## Implementation reconciliation (shipped)
- **Store:** `IEncounterCompleteEventStore` + `InMemoryEncounterCompleteEventStore` — idempotent `TryRecord` / `TryGet` keyed by `(playerId, encounterId)`.
- **Wiring:** `EncounterCompletionOperations.TryCompleteAndGrant` records event after successful completion mark; `EncounterCombatWiring` + `AbilityCastApi` pass store through DI.
- **Hook:** Expanded E7.M2 `reward_delivery` TODO comments; [E7_M2](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) implementation anchor.
- **Tests:** `InMemoryEncounterCompleteEventStoreTests`; extended `EncounterCompletionOperationsTests`, `EncounterCombatWiringTests`, `EncounterDefinitionRegistryTests`.
- **Docs:** `server/README.md`; E5.M3 module snapshot + alignment register + backlog + module dependency register updated.
## Technical approach
1. **`IEncounterCompleteEventStore`**
- **`bool TryRecord(EncounterCompleteEvent completeEvent)`** — `true` on first insert for normalized **`(playerId, encounterId)`**; `false` if already recorded (idempotent no-op).
- **`bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent)`** — read back stored event for tests / future HTTP.
2. **`InMemoryEncounterCompleteEventStore`**
- Key: **`EncounterProgressIds.MakeProgressKey(playerId, encounterId)`** (same as progress/completion stores).
- Value: full **`EncounterCompleteEvent`** struct (including **`GrantedItems`** snapshot at record time).
- Thread-safe: **`ConcurrentDictionary`** + per-key locks (mirror **`InMemoryEncounterCompletionStore`**).
3. **`EncounterCompletionOperations.TryCompleteAndGrant`**
- Add parameter **`IEncounterCompleteEventStore completeEventStore`**.
- After **`TryMarkCompleted`** succeeds and **`EncounterCompleteEvent`** is built:
- Call **`completeEventStore.TryRecord(completeEvent)`**.
- If **`TryRecord`** returns `false` (unexpected after fresh mark — invariant violation / race), **do not rollback** grants or completion flag; treat as idempotent success (event already present for this key). Document in README.
- Replace terse TODO with expanded E7.M2 hook block:
```csharp
// TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent
// (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent.
// Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2.
```
4. **DI**
- Register in **`EncounterCatalogServiceCollectionExtensions.AddEncounterAndRewardCatalogs`**:
- **`services.AddSingleton<IEncounterCompleteEventStore, InMemoryEncounterCompleteEventStore>();`**
5. **Call chain updates**
- **`EncounterCombatWiring.TryProcessCastOutcome`** — resolve **`IEncounterCompleteEventStore`** from deps; pass to **`TryCompleteAndGrant`**.
- **`AbilityCastApi`** — inject **`IEncounterCompleteEventStore`** into wiring call (alongside existing encounter/inventory deps).
6. **Docs**
- **`server/README.md`** — new subsection under encounter completion: event store, idempotency, E7.M2 deferral.
- **`E5_M3_EncounterAndRewardTables.md`** — implementation snapshot for E5M3-09.
- **`E7_M2_RewardAndUnlockRouter.md`** — implementation anchor: **`EncounterCompleteEvent`** producer + future consumer hook.
- Backlog / alignment register / module dependency register — note NEO-107 when landed.
### Prototype completion → event flow (frozen)
| Step | Expected |
|------|----------|
| First **`TryCompleteAndGrant`** success | Completion store marked; **`TryRecord`** stores event with scrap ×10 + token ×1 summary |
| Second **`TryCompleteAndGrant`** | Deny **`already_completed`** before record; event store unchanged |
| Inventory deny / **`not_ready`** | No completion mark; event store empty for that encounter |
| Cast path (NEO-106) | Third defeat → same record via **`TryCompleteAndGrant`** |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/IEncounterCompleteEventStore.cs` | Injectable event persistence contract. |
| `server/NeonSprawl.Server/Game/Encounters/InMemoryEncounterCompleteEventStore.cs` | Thread-safe in-memory implementation (NEO-107). |
| `server/NeonSprawl.Server.Tests/Game/Encounters/InMemoryEncounterCompleteEventStoreTests.cs` | AAA store idempotency + TryGet tests. |
| `docs/plans/NEO-107-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Accept event store; **`TryRecord`** on success; expand E7.M2 hook comments. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs` | Update XML — persistence landed on NEO-107. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Pass **`IEncounterCompleteEventStore`** into **`TryCompleteAndGrant`**. |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject event store for wiring call. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register **`IEncounterCompleteEventStore`** singleton. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCompletionOperationsTests.cs` | Assert event recorded once; replay / deny paths leave store empty; update **`ResolveDependencies`**. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCombatWiringTests.cs` | Assert event store populated after third-defeat wiring completion. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs` | Host DI resolves **`IEncounterCompleteEventStore`** (if not already covered). |
| `server/README.md` | Document event store + E7.M2 hook stub; remove NEO-107 deferral notes. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-09 event persistence. |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | Implementation anchor — **`EncounterCompleteEvent`** producer hook. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-107 event record when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-107 event store when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-09 AC when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `InMemoryEncounterCompleteEventStoreTests.cs` | **Arrange:** prototype **`EncounterCompleteEvent`**. **Act/Assert:** first **`TryRecord`** → `true` + **`TryGet`** round-trip; second **`TryRecord`** same key → `false`; empty key / invalid ids fail closed. |
| `EncounterCompletionOperationsTests.cs` | **Extend existing grant-once test:** assert **`TryGet`** returns event matching **`CompleteEvent`**; **`already_completed`** / **`not_ready`** / **`inventory_full`** paths assert store still empty (or unchanged count). |
| `EncounterCombatWiringTests.cs` | **Extend third-defeat completion test:** assert event store has record after wiring success. |
| `EncounterDefinitionRegistryTests.cs` | **Assert:** host startup resolves **`IEncounterCompleteEventStore`** as **`InMemoryEncounterCompleteEventStore`**. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Record after mark ordering** | Record only after **`TryMarkCompleted`** succeeds — event mirrors committed completion. | **adopted** |
| **TryRecord false after fresh mark** | Treat as idempotent (event already there); no grant rollback — same key invariant as completion store. | **adopted** |
| **NEO-108 grant summary** | HTTP read may use event store **`GrantedItems`** or completion + inventory delta — defer projection design to NEO-108; store exposes **`TryGet`**. | **deferred** |
| **Postgres follow-on** | Defer — session persistence migration is a future issue. | **deferred** |

View File

@ -0,0 +1,168 @@
# NEO-108 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-108 |
| **Title** | E5M3-08: GET encounter-progress + wire DTOs + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-108/e5m3-08-get-encounter-progress-wire-dtos-bruno |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-08** |
| **Branch** | `NEO-108-get-encounter-progress-wire-dtos-bruno` |
| **Precursor** | [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) progress + completion stores · [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants · [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring · [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) complete event store (**landed on `main`**) |
| **Pattern** | [NEO-103](NEO-103-implementation-plan.md) — world/player GET + Bruno; [NEO-44](NEO-44-implementation-plan.md) — player snapshot GET + position gate; [NEO-106](NEO-106-implementation-plan.md) — defeat-all-three cast helpers |
| **Blocks** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — client encounter HUD poll/refresh |
| **Client counterpart** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) — Godot labels poll this GET; **not in this story** |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **`rewardGrantSummary` source** | Event store vs reward table vs fallback? | **`IEncounterCompleteEventStore.GrantedItems`** on `completed` — commit-time snapshot (NEO-107). | **Adopted** — event store |
| **Encounter rows in GET** | All catalog encounters vs only non-inactive? | **All** from **`GetDefinitionsInIdOrder()`** with derived **`inactive` / `active` / `completed`**. | **Adopted** (backlog) |
| **Player gate** | 404 for unknown player? | **`IPositionStateStore.TryGetPosition`** — gig/skill progression precedent. | **Adopted** |
| **Optional fields** | `completedAt` / `rewardGrantSummary` when not complete? | **Omit** (or JSON `null`) when not **`completed`**; **`defeatedTargetIds`** = `[]` when inactive. | **Adopted** |
| **Integration tests** | Dedicated API tests vs cast-only? | **`EncounterProgressApiTests`** + Bruno three-NPC spine; optional GET assertions after existing cast tests deferred unless useful. | **Adopted** |
| **Manual QA doc** | `docs/manual-qa/NEO-108.md`? | **Skip** — server-only (NEO-103 precedent); Bruno + automated API tests. | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** Expose authoritative per-player encounter progress over HTTP so clients and Bruno can read server-owned activation, defeat subset, completion timestamp, and grant summary without inferring from combat responses.
**In scope (from Linear + [E5M3-08](E5M3-prototype-backlog.md#e5m3-08--get-gameplayersidencounter-progress--wire-dtos)):**
- **`GET /game/players/{id}/encounter-progress`** with versioned envelope (`schemaVersion` **1**, **`encounters`** array).
- Per-encounter row: **`encounterId`**, **`state`** (`inactive` | `active` | `completed`), **`defeatedTargetIds`**, optional **`completedAt`**, optional **`rewardGrantSummary`** (`itemId` + `quantity` grants).
- Projection from **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IEncounterCompleteEventStore`** (no second source of truth).
- **`EncounterProgressApi`** + wire DTOs in `Game/Encounters/`.
- Integration tests (AAA): unknown player 404; inactive default; partial progress after 12 defeats; completed once with grant summary.
- Bruno `bruno/neon-sprawl-server/encounter-progress/` — self-contained three-NPC defeat pre-request → GET **`completed`** once.
- `server/README.md` route section; E5.M3 module snapshot + alignment register when landed.
**Out of scope (from Linear + backlog):**
- Godot poll client — **client counterpart [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)**.
- Telemetry hook sites ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)).
- Postgres persistence, cast response DTO changes.
- `docs/manual-qa/NEO-108.md` (server-only per kickoff).
## Acceptance criteria checklist
- [x] GET reflects progress after each defeat without client-side inference.
- [x] Completed encounter shows **`completed`** state and grant summary.
- [x] Bruno smoke: defeat chain → GET shows **`completed`** once.
## Implementation reconciliation (shipped)
- **Route:** `GET /game/players/{id}/encounter-progress``EncounterProgressApi.BuildSnapshot` projects progress, completion, and event stores; 404 via `IPositionStateStore`.
- **DTOs:** `EncounterProgressListResponse` (schema v1); row `state` inactive/active/completed; `rewardGrantSummary` from `IEncounterCompleteEventStore.GrantedItems`; optional fields omitted when not completed.
- **Tests:** five AAA tests in `EncounterProgressApiTests` (404, inactive, 1-defeat active, 2-defeat active, completed + grant summary).
- **Bruno:** `bruno/neon-sprawl-server/encounter-progress/` — inactive GET + three-NPC defeat spine.
- **Docs:** `server/README.md`; E5.M3 module snapshot; alignment register; module dependency register; backlog E5M3-08 AC.
## Technical approach
1. **Route:** **`GET /game/players/{id}/encounter-progress`** — inject **`IPositionStateStore`**, **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, **`IEncounterCompleteEventStore`**.
2. **Player gate:** Trim `id`; return **404** when empty or position missing (mirror **`GigProgressionSnapshotApi`**).
3. **State derivation** (per catalog encounter id, normalized lowercase keys in stores):
| Condition | **`state`** |
|-----------|-------------|
| **`IEncounterCompletionStore.IsCompleted`** | **`completed`** |
| Progress snapshot **`Started`** | **`active`** |
| Otherwise | **`inactive`** |
4. **Row fields:**
- **`encounterId`**: catalog **`id`** (lowercase).
- **`defeatedTargetIds`**: from **`EncounterProgressSnapshot.DefeatedNpcInstanceIds`** when progress exists; else **`[]`** (sorted ordinal for stable JSON).
- **`completedAt`**: ISO-8601 UTC from **`IEncounterCompletionStore.TryGetCompletedAt`** when **`completed`**; omitted otherwise.
- **`rewardGrantSummary`**: when **`completed`**, map **`EncounterCompleteEvent.GrantedItems`** from **`IEncounterCompleteEventStore.TryGet`** → `{ itemId, quantity }[]`. If completion flag set but event missing (invariant violation), throw **`InvalidOperationException`** (fail-fast; same spirit as NEO-103 missing reward table). Omitted when not **`completed`**.
5. **Ordering:** Build rows from **`encounterRegistry.GetDefinitionsInIdOrder()`** (prototype: single **`prototype_combat_pocket`**).
6. **Implementation:** **`EncounterProgressApi`** + **`EncounterProgressListDtos.cs`** with internal **`BuildSnapshot(playerId, …)`** testable like **`GigProgressionSnapshotApi.BuildSnapshot`**. Register **`app.MapEncounterProgressApi()`** in **`Program.cs`** after **`MapGigProgressionSnapshotApi()`**.
7. **Bruno:** `bruno/neon-sprawl-server/encounter-progress/`
- **`folder.bru`**
- **`Get encounter progress inactive.bru`** — GET before engagement → **`inactive`**, empty defeats.
- **`Get encounter progress after defeat spine.bru`** — pre-request: **`resetPrototypeCombatTargets`** (shared helper pattern from gig-progression), move, bind slot **3** pulse, defeat melee → ranged → elite via cast; GET asserts **`active`** with growing **`defeatedTargetIds`** after 12 defeats (optional intermediate GETs in pre-request) and final **`completed`** with **`rewardGrantSummary`** matching **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**; second GET still **`completed`** (idempotent read).
8. **Docs:** Update **`server/README.md`** (replace NEO-108 deferral under encounter progress section). Update [E5_M3](E5_M3_EncounterAndRewardTables.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md), [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) when landed.
### Expected prototype row progression (frozen)
| After | **`state`** | **`defeatedTargetIds`** | **`rewardGrantSummary`** |
|-------|-------------|-------------------------|--------------------------|
| No engagement | `inactive` | `[]` | omitted |
| First damaging cast vs required NPC | `active` | 03 ids | omitted |
| 12 lethal defeats | `active` | 12 ids | omitted |
| Third lethal defeat + grants | `completed` | 3 ids | scrap ×10, token ×1 |
| Subsequent GET / casts | `completed` | 3 ids | unchanged summary |
### Prototype completion read flow
```mermaid
sequenceDiagram
participant Client
participant API as EncounterProgressApi
participant Pos as IPositionStateStore
participant Reg as IEncounterDefinitionRegistry
participant Prog as IEncounterProgressStore
participant Comp as IEncounterCompletionStore
participant Ev as IEncounterCompleteEventStore
Client->>API: GET /game/players/{id}/encounter-progress
API->>Pos: TryGetPosition
alt unknown player
API-->>Client: 404
end
API->>Reg: GetDefinitionsInIdOrder
loop each encounter
API->>Comp: IsCompleted
API->>Prog: TryGetProgress
API->>Ev: TryGet when completed
end
API-->>Client: schemaVersion 1 + encounters[]
```
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressApi.cs` | `Map*` extension for GET route; **`BuildSnapshot`** projection. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressListDtos.cs` | Versioned response + per-encounter row + grant summary DTOs. |
| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterProgressApiTests.cs` | AAA HTTP: 404, inactive, partial active, completed + grant summary. |
| `bruno/neon-sprawl-server/encounter-progress/folder.bru` | Bruno folder metadata. |
| `bruno/neon-sprawl-server/encounter-progress/Get encounter progress inactive.bru` | GET default **`inactive`**. |
| `bruno/neon-sprawl-server/encounter-progress/Get encounter progress after defeat spine.bru` | Three-NPC defeat pre-request → **`completed`** once. |
| `docs/plans/NEO-108-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapEncounterProgressApi()`** after gig progression routes. |
| `server/README.md` | Document GET route, curl, store projection, Bruno folder; remove NEO-108 deferral notes. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Implementation snapshot — E5M3-08 HTTP read model. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-108 HTTP when landed. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — NEO-108 per-player GET when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | Mark E5M3-08 acceptance criteria when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `EncounterProgressApiTests.cs` | **404** for unknown player. **Inactive:** fresh factory → GET → one encounter **`prototype_combat_pocket`**, **`inactive`**, empty defeats, no **`completedAt`** / **`rewardGrantSummary`**. **Partial:** cast/defeat helpers (reuse **`AbilityCastApiTests`** pattern or inline HTTP) — after 1 defeat **`active`** + 1 id; after 2 defeats still **`active`** + 2 ids, not **`completed`**. **Completed:** defeat all three → **`completed`**, **`completedAt`** present, **`rewardGrantSummary`** quantities 10 + 1; completion store + event store aligned. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Event missing when completed** | Throw **`InvalidOperationException`** at projection time — should not happen after NEO-107; surfaces wiring bugs in tests. | **adopted** |
| **Bruno slot cooldown** | Use hotbar slot **3** (or next free) in defeat spine — avoid slot 0/1 conflicts with other Bruno folders (NEO-44 precedent). | **adopted** |
| **Case on `defeatedTargetIds`** | Return normalized lowercase ids from store (catalog ids are lowercase). | **adopted** |
| **NEO-110 poll cadence** | Defer — client story chooses poll-after-cast vs interval. | **deferred** |

View File

@ -0,0 +1,143 @@
# NEO-109 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-109 |
| **Title** | E5M3-10: Slice 3 encounter telemetry hook sites |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-109/e5m3-10-slice-3-encounter-telemetry-hook-sites |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-10** |
| **Branch** | `NEO-109-slice-3-encounter-telemetry-hook-sites` |
| **Blocked by** | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) — combat wiring (**Done** on `main`); [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) — event record (**Done** on `main`) |
| **Pattern** | [NEO-49](NEO-49-implementation-plan.md) / [NEO-64](NEO-64-implementation-plan.md) — comment-only engine anchors; [NEO-84](NEO-84-implementation-plan.md) — reserved `encounter_start` in Slice 1 combat |
| **Blocks** | None — client capstone [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) / [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) do not depend on telemetry |
| **Client counterpart** | None — server-only comments; player-visible HUD is [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **`reward_attribution` shape** | Once per completion vs per grant vs skip? | **Once per successful completion** — batch payload after all grants + completion mark commit; `item_created` remains at inventory layer (**NEO-56**). | **Adopted** — once per completion |
| **`encounter_complete` anchor** | Single engine anchor vs dual wiring + ops? | **`EncounterCompletionOperations.TryCompleteAndGrant` only** — remove duplicate in **`EncounterCombatWiring`** (**NEO-49** / **NEO-64** precedent). | **Adopted** — completion ops only |
| **Manual QA doc** | `docs/manual-qa/NEO-109.md`? | **Yes** (NEO-64 pattern). | **Adopted****skip** (NEO-108 server-only precedent per user) |
| **`encounter_complete_denied`** | Keep deny hook on `Deny`? | **Keep** — already landed from NEO-105 review; **NEO-56** deny precedent; not in epic vocabulary but comments-only. | **Adopted** (implicit — no change) |
| **Runtime behavior** | Comments-only vs dev `ILogger`? | **Comments-only**`TODO(E9.M1)`; no production logging (**NEO-49** precedent). | **Adopted** (repo precedent; not asked) |
## Goal, scope, and out-of-scope
**Goal:** Document and place **comment-only hook sites** on the server-authoritative encounter funnel for Epic 5 Slice 3 product telemetry **`encounter_start`**, **`encounter_complete`**, and **`reward_attribution`**, plus consolidate anchors so E9.M1 wiring has one documented surface per event.
**In scope (from Linear + [E5M3-10](E5M3-prototype-backlog.md#e5m3-10--slice-3-encounter-telemetry-hook-sites)):**
- **`TODO(E9.M1)`** markers at encounter start, completion commit, and **once** reward attribution on successful grant batch.
- **`server/README.md`** encounter telemetry subsection; epic Slice 3 vocabulary cross-link.
- Normalize partial NEO-109 stubs from NEO-104/105/106 into full **NEO-64**-style comment blocks (event name, payload fields, no ingest).
- Update **NEO-84** reserved **`encounter_start`** pointer in **`CombatOperations`** to E5.M3 anchor (comments only).
- E5M3 backlog + alignment register + module snapshot when implementation completes.
**Out of scope (from Linear + backlog):**
- Production ingest, dashboards (**E9.M1**).
- `ILogger` / metrics / dev-only log lines.
- Godot client, Bruno changes, API/wire contract changes.
- `docs/manual-qa/NEO-109.md` (kickoff: skip).
- Duplicate hook comments in **`AbilityCastApi`** or **`EncounterCombatWiring`** for **`encounter_complete`** / **`reward_attribution`**.
## Acceptance criteria checklist
- [x] Hook names match [epic_05 Slice 3](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3) vocabulary (`encounter_complete`, reward attribution / **`reward_attribution`**).
- [x] No runtime behavior change beyond comments.
## Implementation reconciliation (shipped)
- **`encounter_start`:** full comment block in **`EncounterProgressOperations.TryActivateOnFirstEngagement`** after successful **`TryActivate`** (anchor matches once-per-activation emit); NEO-84 **`CombatOperations`** reserved pointer updated.
- **`encounter_complete`** + **`reward_attribution`:** full comment blocks in **`EncounterCompletionOperations.TryCompleteAndGrant`** after event record; duplicate removed from **`EncounterCombatWiring`** (delegate pointer only).
- **`encounter_complete_denied`:** retained on **`Deny`**.
- **Docs:** `server/README.md` encounter telemetry subsection; E5.M3 module snapshot; alignment register; module dependency register; E5M3-10 backlog landed note.
## Technical approach
### Current state (on `main`)
Partial **NEO-109** tags already exist from precursor stories:
| Event | File | Status |
|-------|------|--------|
| **`encounter_start`** | `EncounterProgressOperations.TryActivateOnFirstEngagement` | Stub present — expand to full block |
| **`encounter_complete`** | `EncounterCompletionOperations` success path | Thin `TODO` — expand; **remove** `EncounterCombatWiring` duplicate |
| **`encounter_complete_denied`** | `EncounterCompletionOperations.Deny` | Block present — keep |
| **`reward_attribution`** | — | **Missing** — add on completion success path |
### Hook sites (after implementation)
1. **`encounter_start`** — **`EncounterProgressOperations.TryActivateOnFirstEngagement`**, immediately before **`progressStore.TryActivate`** when activation will proceed (not when already started/completed/no encounter):
- Comment block: future E9.M1 event **`encounter_start`**.
- **`TODO(E9.M1): catalog emit`** — once per player+encounter on first activation.
- Payload: `playerId`, `encounterId`, `npcInstanceId` (engagement target), optional `requiredNpcInstanceIds` count.
- **NEO-84:** update class-level reserved comment in **`CombatOperations`** to point here (Slice 3 landed).
2. **`encounter_complete`** — **`EncounterCompletionOperations.TryCompleteAndGrant`**, after **`TryMarkCompleted`** + **`TryRecord`** succeed (authoritative commit):
- Replace thin TODO with full block naming **`encounter_complete`**.
- Payload: `playerId`, `encounterId`, `rewardTableId`, `completedAt`, `idempotencyKey`, `grantedItems` summary from **`EncounterCompleteEvent`**.
- **Remove** duplicate hook block at end of **`EncounterCombatWiring.TryProcessCastOutcome`** (wiring only calls completion ops).
3. **`reward_attribution`** — same success path, **once per completion** (kickoff decision):
- Comment block naming **`reward_attribution`** (snake_case catalog name; epic prose “reward attribution”).
- **`TODO(E9.M1): catalog emit`** — once when reward table grants commit for encounter completion.
- Payload: `playerId`, `encounterId`, `rewardTableId`, `grantedItems[]` (batch from **`EncounterGrantApplied`** / event store snapshot).
- Note in comment: inventory layer still emits **`item_created`** per stack via **`PlayerInventoryOperations`** (**NEO-56**); **`reward_attribution`** is encounter-scoped attribution for E7.M2 / loot analytics.
4. **`encounter_complete_denied`** — keep existing **`Deny`** block ( **`reasonCode`** from **`EncounterCompletionReasonCodes`** ).
5. **Cross-references:**
- **`EncounterCompletionResult`** / **`EncounterCompleteEvent`** xml summaries → telemetry anchors in **`EncounterCompletionOperations`**.
- **`EncounterProgressOperations`** class summary → **`encounter_start`** anchor.
- **`server/README.md`:** new **Encounter telemetry hooks (NEO-109)** subsection (after NEO-108 or NEO-106 block) listing all three epic events + deny + **`CombatOperations`** reserved pointer.
- **`E5_M3_EncounterAndRewardTables.md`**, **`documentation_and_implementation_alignment.md`**, **`module_dependency_register.md`**, **`E5M3-prototype-backlog.md`:** E5M3-10 landed note when complete.
### NEO-56 / NEO-64 analogy
| Layer | Inventory / gather | Encounter Slice 3 |
|-------|-------------------|-------------------|
| Low-level item mutation | **`item_created`** (**NEO-56**) | Same on each **`TryAddStack`** during grants |
| Orchestration / funnel | **`resource_gathered`** (**NEO-64**) | **`encounter_complete`** + **`reward_attribution`** (**NEO-109**) at completion commit |
| Engagement | — | **`encounter_start`** at first activation |
## Files to add
None.
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperations.cs` | Expand **`encounter_complete`** block; add **`reward_attribution`** once on success; keep **`encounter_complete_denied`** on **`Deny`**. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCombatWiring.cs` | Remove duplicate **`encounter_complete`** hook; one-line pointer to **`EncounterCompletionOperations`**. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterProgressOperations.cs` | Expand **`encounter_start`** block to NEO-64 style; class summary cross-ref. |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompletionResult.cs` | Xml summary → telemetry hook anchor (no behavior change). |
| `server/NeonSprawl.Server/Game/Encounters/EncounterCompleteEvent.cs` | Xml summary → completion + attribution hook anchor (no behavior change). |
| `server/NeonSprawl.Server/Game/Combat/CombatOperations.cs` | Update reserved **`encounter_start`** comment → E5.M3 **`EncounterProgressOperations`** anchor. |
| `server/README.md` | **NEO-109** encounter telemetry hooks subsection; fix NEO-104 vs NEO-109 label on **`encounter_start`** in NEO-106 table if needed. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | NEO-109 telemetry hooks landed line. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row: NEO-109 hook sites. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 note — E5M3-10 complete when landed. |
| `docs/plans/E5M3-prototype-backlog.md` | E5M3-10 acceptance checkboxes + landed note when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| *(none added or changed)* | Comments-only; no behavior or wire contract change. Regression: `dotnet test NeonSprawl.sln` (existing **`EncounterCompletionOperationsTests`**, **`EncounterProgressOperationsTests`**, cast integration tests unchanged). |
**Manual verification (no checklist doc):** grep `NEO-109` / `encounter_complete` / `reward_attribution` / `encounter_start` under `server/NeonSprawl.Server/Game/Encounters/`; confirm **`EncounterCombatWiring`** has no **`encounter_complete`** emit comment; confirm no new **`ILogger`** or ingest calls.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Overlap with NEO-56 `item_created`** | Keep both — **`item_created`** per stack; **`reward_attribution`** once per encounter completion for attribution context. | **adopted** (kickoff) |
| **`encounter_complete_denied` not in epic list** | Keep deny hook — **NEO-56** precedent; zero runtime cost. | **adopted** |
| **Precursor partial stubs** | Treat as draft; NEO-109 normalizes style and removes wiring duplicate. | **adopted** |
None blocking.

View File

@ -0,0 +1,161 @@
# NEO-110 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-110 |
| **Title** | E5M3-11: Client encounter progress + loot feedback HUD |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-110/e5m3-11-client-encounter-progress-loot-feedback-hud |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-11** |
| **Branch** | `NEO-110-client-encounter-progress-loot-feedback-hud` |
| **Server deps** | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) — `GET /game/players/{id}/encounter-progress` (**Done** on `main`); [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) — complete event + grant summary (**Done** on `main`) |
| **Pattern** | [NEO-72](NEO-72-implementation-plan.md) / [NEO-86](NEO-86-implementation-plan.md) — thin HTTP client, `main.gd` HUD render, defeat-triggered refresh; [NEO-73](NEO-73-implementation-plan.md) — inventory refresh after server-side success |
| **Blocks** | [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) — playable encounter clear capstone (Godot manual QA) |
| **Server counterpart** | [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) — authoritative GET; Bruno is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Refresh strategy** | When to call encounter-progress GET? | **Boot hydrate + refresh after each cast with `targetDefeated`** — mirrors NEO-86 gig refresh; no periodic poll (NEO-108 deferred cadence to this story). | **Adopted** — boot + after `targetDefeated` |
| **Progress denominator** | How to show **2/3** targets? | **Hardcode 3** for frozen **`prototype_combat_pocket`** — single prototype encounter; avoids new encounter-definitions client (NEO-103 server-only). | **Adopted** — constant `3` |
| **Grant display names** | Loot lines on completion label? | **Reuse `ItemDefinitionsClient.display_name_for`** — NEO-72 precedent; fallback raw **`itemId`**. | **Adopted** — reuse item defs |
| **HUD placement** | Where do the two labels live? | **`UICanvas/HudRoot`** after combat HP labels — visible during Tab/cast loop (not buried in economy collapse). | **Adopted** — HudRoot combat area |
| **Manual refresh key** | Dedicated input action? | **Skip** — defeat-driven refresh + boot suffice for AC; NEO-111 capstone is the full Godot script. | **Adopted** (implicit — no new key) |
## Goal, scope, and out-of-scope
**Goal:** Godot shows **server-owned** encounter progress and completion loot using **`GET /game/players/{id}/encounter-progress`** — player-visible **N/3** defeat progress after each NPC kill and a completion grant summary matching the GET body.
**In scope (from Linear + [E5M3-11](E5M3-prototype-backlog.md#e5m3-11--client-encounter-progress-loot-feedback-hud)):**
- **`encounter_progress_client.gd`:** GET snapshot; parse v1 envelope; **`encounter_row(encounter_id)`** helper; signals **`encounter_progress_received`** / **`encounter_sync_failed`**; public **`request_sync_from_server()`**; **`push_warning`** on failures (NEO-72 pattern).
- **`EncounterProgressLabel`** + **`EncounterCompleteLabel`** under **`UICanvas/HudRoot`** (after **`PlayerCombatHpLabel`**).
- **`main.gd`:** boot hydrate; on accepted cast with **`targetDefeated: true`** → encounter-progress GET (alongside existing gig/combat refreshes); render progress **`defeatedTargetIds.size() / 3`** for **`prototype_combat_pocket`** when **`state`** is **`inactive`** or **`active`**; on **`completed`** paint grant summary from **`rewardGrantSummary`** and **`_request_inventory_refresh()`** (NEO-72 inventory HUD).
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-110.md`** — Godot steps (server + client; no Bruno).
- **`client/README.md`** encounter HUD subsection.
**Out of scope (from Linear + backlog):**
- Final loot VFX art; encounter picker UI beyond frozen pocket.
- **`GET /game/world/encounter-definitions`** client (denominator hardcoded).
- Periodic encounter poll timer; dedicated manual-refresh key.
- Server route/DTO changes.
- **`docs/manual-qa/NEO-111.md`** (capstone story).
## Acceptance criteria checklist
- [x] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets).
- [x] Completion label shows grant summary matching server GET.
- [x] Manual QA checklist exercisable without Bruno.
## Implementation reconciliation (shipped)
**Manual QA:** [`docs/manual-qa/NEO-110.md`](../manual-qa/NEO-110.md) — passed (Godot defeat chain, progress **1/3 → 2/3 → completed**, loot + inventory). **PR #149** merged to `main`.
- **`encounter_progress_client.gd`:** GET parse v1; **`encounter_row`**; **`encounter_progress_received`** / **`encounter_sync_failed`**.
- **`main.gd`:** boot + **`targetDefeated`** refresh; HUD render; inventory refresh on **`completed`**; item-defs re-paint when defs arrive.
- **Scene:** **`EncounterProgressClient`**, **`EncounterProgressLabel`**, **`EncounterCompleteLabel`** in **`HudRoot`** after **`PlayerCombatHpLabel`**.
- **Tests:** `client/test/encounter_progress_client_test.gd` (inactive/active/completed parse + 404).
- **Docs:** `client/README.md` encounter section; `docs/manual-qa/NEO-110.md`.
## Technical approach
### Server contract (landed — NEO-108)
- **`GET /game/players/{id}/encounter-progress`** → **`schemaVersion` 1**, **`encounters[]`** rows with **`encounterId`**, **`state`** (`inactive` | `active` | `completed`), **`defeatedTargetIds`**, optional **`completedAt`**, optional **`rewardGrantSummary`** (`{ itemId, quantity }[]`).
- Prototype row **`prototype_combat_pocket`**: inactive → **0/3**; after defeats → **`active`** with growing **`defeatedTargetIds`**; all three E5.M2 NPCs defeated → **`completed`** with **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**.
### Client constants (`main.gd`)
```gdscript
const PROTOTYPE_ENCOUNTER_ID := "prototype_combat_pocket"
const PROTOTYPE_ENCOUNTER_REQUIRED_TARGETS := 3
```
### 1. `encounter_progress_client.gd`
- Mirror **`gig_progression_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/encounter-progress`**.
- **`parse_encounter_progress_json(text)`** static: require **`schemaVersion` 1**, **`encounters`** array.
- **`encounter_row(encounter_id, snapshot)`** → row dict or `{}`.
- Signals: **`encounter_progress_received(snapshot)`**, **`encounter_sync_failed(reason)`**.
### 2. Boot + refresh wiring (`main.gd`)
- **`_setup_encounter_progress_sync()`** from **`_ready()`** after authority HTTP config (same as gig/inventory).
- **`EncounterProgressClient`** node in **`main.tscn`**; **`_apply_authority_http_config_to_client`**.
- Boot: **`request_sync_from_server()`** once catalogs/inventory chain has started (parallel with gig boot is fine).
- **`_on_cast_result_received`:** when **`accepted`** and **`resolution.targetDefeated`** → **`_request_encounter_progress_refresh()`** (in addition to existing cooldown/combat/gig refreshes).
### 3. HUD rendering
**`EncounterProgressLabel`** (header **`Encounter:`**):
| **`state`** | Copy |
|-------------|------|
| **`inactive`** | **`prototype_combat_pocket: not started (0/3)`** |
| **`active`** | **`prototype_combat_pocket: {n}/3`** where **`n = defeatedTargetIds.size()`** |
| **`completed`** | **`prototype_combat_pocket: completed (3/3)`** |
| sync error | **`Encounter: error — {reason}`** |
**`EncounterCompleteLabel`** (header **`Loot:`**):
| **`state`** | Copy |
|-------------|------|
| not **`completed`** | **`Loot: —`** |
| **`completed`** | One line per **`rewardGrantSummary`** grant: **`{displayName or itemId} ×{quantity}`**; optional **`completedAt`** suffix on header line |
| sync error | **`Loot: error — {reason}`** (or leave progress label as primary error — prefer single error on progress label, complete shows **`—`**) |
**Inventory:** on **`encounter_progress_received`**, if prototype row **`state == "completed"`** → **`_request_inventory_refresh()`** so **`InventoryLabel`** reflects grants without manual **I** (backlog + NEO-73 precedent).
### 4. Scene
- **`main.tscn`:** add **`EncounterProgressClient`** node; **`EncounterProgressLabel`** + **`EncounterCompleteLabel`** as **`HudRoot`** children **after** **`PlayerCombatHpLabel`**, **before** **`NpcStateLabel`** (VBox order).
### 5. Tests (GdUnit, AAA)
- Happy path: v1 JSON with one **`active`** row, two defeats → **`encounter_row`** returns **`defeatedTargetIds.size() == 2`**.
- **`completed`** row includes **`rewardGrantSummary`** array parse.
- 404 → **`encounter_sync_failed`** + **`push_warning`**.
- Optional: static/format helper test for **`main`** render strings (only if extracted to **`encounter_progress_hud_format.gd`** — prefer keeping format in **`main.gd`** unless tests need it; client test focuses on HTTP client).
### 6. Docs on land
- **`docs/manual-qa/NEO-110.md`**: fresh server; Godot **F5**; Tab + cast three prototype NPCs; assert progress **1/3 → 2/3 → 3/3**; completion loot lines match GET; inventory shows grants without **I**.
- Update [E5_M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) client slice note, [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) E5M3-11 checkboxes, [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-110-implementation-plan.md` | This plan. |
| `client/scripts/encounter_progress_client.gd` | GET encounter-progress; parse v1; **`encounter_row`** helper; signals. |
| `client/scripts/encounter_progress_client.gd.uid` | Godot uid companion (tracked). |
| `client/test/encounter_progress_client_test.gd` | GdUnit: parse active/completed rows; 404 failure signal. |
| `docs/manual-qa/NEO-110.md` | Godot manual QA (server + client; defeat chain + HUD assertions). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scenes/main.tscn` | Add **`EncounterProgressClient`**, **`EncounterProgressLabel`**, **`EncounterCompleteLabel`** nodes. |
| `client/scripts/main.gd` | Boot/refresh wiring, defeat-triggered GET, HUD render, inventory refresh on **`completed`**. |
| `client/README.md` | Encounter progress + loot HUD subsection: server dep NEO-108, refresh triggers, label format. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `client/test/encounter_progress_client_test.gd` | Mock 200: **`inactive`** row parse; **`active`** with two **`defeatedTargetIds`**; **`completed`** + **`rewardGrantSummary`**; 404 → **`encounter_sync_failed`**. AAA layout (`# Arrange` / `# Act` / `# Assert`). |
No new **C#** tests (client-only; server covered by **`EncounterProgressApiTests`**). No Bruno changes.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|----------------------|--------|
| **Concurrent GETs on triple-defeat frame** | **`_busy`** guard drops overlap — last completed response wins; acceptable for prototype. | **adopted** |
| **Item defs not loaded before completion render** | Re-render encounter labels when **`definitions_ready`** fires if snapshot already **`completed`** (mirror NEO-72 inventory re-paint). | **adopted**`_on_item_definitions_ready` re-paints encounter HUD |
| **NEO-111 capstone overlap** | NEO-110 manual QA is HUD-focused; NEO-111 adds full loop + idempotency — keep scopes separate. | **deferred** |

View File

@ -0,0 +1,160 @@
# NEO-111 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-111 |
| **Title** | E5M3-12: Playable encounter clear capstone (Godot) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-111/e5m3-12-playable-encounter-clear-capstone-godot |
| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-12** |
| **Branch** | `NEO-111-playable-encounter-clear-capstone` |
| **Client deps** | [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) encounter progress + loot HUD (**Done**); E5.M1 cast + gig XP ([NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)); E5.M2 NPC combat ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)) |
| **Server deps** | [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) combat wiring · [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) encounter-progress GET · [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) completion grants (**Done** on `main`) |
| **Pattern** | Capstone integration — [NEO-98](NEO-98-implementation-plan.md) / [NEO-86](NEO-86-implementation-plan.md) docs + manual QA primary; minimal gap-fill only if capstone QA fails on `main` |
| **Server counterpart** | NEO-100NEO-109 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-98](NEO-98-implementation-plan.md) precedent. | **Adopted** — docs-primary |
| **Session baseline** | Fresh server vs fixture POST? | **Fresh server restart** before Godot **F5** — resets NPC HP, encounter progress, inventory, and gig XP; zero Bruno/curl in main checklist. | **Adopted** — fresh restart; no Bruno/curl |
| **Defeat order** | Fixed vs flexible order in script? | **Melee → Ranged → Elite** for script clarity. | **Flexible** — any order; script asserts **0/3 → 3/3** progression only |
| **Idempotency check** | How to prove loot grants once? | **Godot stop + F5 restart** (server still running) + casts on defeated NPCs; inventory counts unchanged. Dev fixture POST clears encounter completion — **not** used. | **Adopted** — Godot restart + defeated-NPC casts |
| **NEO-110 relationship** | Supersede component QA? | **NEO-111 supersedes as Slice 3 capstone**; keep [NEO-110](../manual-qa/NEO-110.md) as component-level regression (NEO-85 / NEO-86 pattern). | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** Prove Epic 5 Slice 3 acceptance **in Godot**: defeat all three E5.M2 NPCs, receive **`scrap_metal_bulk` ×10** + **`contract_handoff_token` ×1** **once**, verify inventory — without Bruno.
**In scope (from Linear + [E5M3-12](E5M3-prototype-backlog.md#e5m3-12--playable-encounter-clear-capstone-godot)):**
- **`docs/manual-qa/NEO-111.md`**: numbered **single-session** capstone script — fresh dev player, **flexible defeat order**, zero Bruno/curl steps in the main checklist; **idempotency** via Godot restart + casts on defeated NPCs.
- **`client/README.md`**: **End-to-end encounter clear loop** section — integration flow table (boot → engage → defeat ×3 → loot → inventory → idempotency); cross-links NEO-106NEO-110.
- **Module alignment** (on story completion): update [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M3 row, [E5_M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) status note, and [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) E5M3-12 checkboxes; mark Epic 5 Slice 3 client capstone complete.
- **Integration fixes only if capstone QA fails** on current `main` wiring (unexpected deny, HUD refresh gap, inventory drift, idempotency breach, etc.).
**Out of scope (from Linear + backlog):**
- Group scaling ([E5.M4](../decomposition/modules/E5_M4_GroupCombatScaling.md)), dynamic spawn ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)).
- Final loot VFX art; encounter selection UI beyond frozen **`prototype_combat_pocket`**.
- Bruno-only verification as prototype-complete proof.
- New server HTTP routes or Godot HUD features beyond NEO-110.
## Acceptance criteria checklist
- [x] Human completes **`docs/manual-qa/NEO-111.md`** with server + client; loot readable in inventory HUD.
- [x] Epic 5 Slice 3 AC: completing defined encounter grants expected items + quest token **once** (idempotency steps pass).
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
## Implementation reconciliation (shipped)
**Manual QA:** [`docs/manual-qa/NEO-111.md`](../manual-qa/NEO-111.md) — passed (Godot defeat chain any order, **0/3 → completed**, loot + inventory, Godot restart idempotency).
- **`docs/manual-qa/NEO-111.md`** — single-session capstone checklist with idempotency + regression steps.
- **`client/README.md`** — **End-to-end encounter clear loop (NEO-111)** section with flow table and cross-links.
- **`docs/manual-qa/NEO-110.md`** — cross-link to NEO-111 capstone.
- **`docs/plans/E5M3-prototype-backlog.md`**, **`E5_M3_EncounterAndRewardTables.md`**, **`documentation_and_implementation_alignment.md`**, **`module_dependency_register.md`** — E5M3-12 landed; E5.M3 **Ready**; Epic 5 Slice 3 client capstone complete.
- **No integration code changes** — docs-primary scope; server encounter spine verified by existing **`EncounterCombatWiringTests`** / **`EncounterProgressApiTests`** / **`AbilityCastApiTests`**.
## Technical approach
### 1. Capstone manual QA script (`docs/manual-qa/NEO-111.md`)
**Preconditions:** Stop any running server; start fresh (`cd server/NeonSprawl.Server && dotnet run`) so in-memory **`dev-local-1`** NPC HP, encounter progress, inventory, and gig XP reset. Godot **F5** with **no** prior curl/Bruno seeding. **`Economy HUD`** toggle **on** for inventory verification.
**Archetype reference** (defeat order **flexible** — server tracks order-independent subset):
| 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 encounter HUD progression** (from NEO-110; count is **`defeatedTargetIds.size()`**):
| After | `EncounterProgressLabel` | `EncounterCompleteLabel` |
|-------|--------------------------|---------------------------|
| Boot | `prototype_combat_pocket: not started (0/3)` | `Loot: —` |
| 1st NPC defeated | `prototype_combat_pocket: 1/3` | `Loot: —` |
| 2nd NPC defeated | `prototype_combat_pocket: 2/3` | `Loot: —` |
| 3rd NPC defeated | `prototype_combat_pocket: completed (3/3)` | `scrap_metal_bulk ×10` + `contract_handoff_token ×1` |
| Idempotency (post-restart) | still **`completed (3/3)`** | unchanged grant lines |
**Main checklist spine:**
1. Start server + Godot **F5**; confirm boot encounter HUD **0/3**.
2. Defeat **three** prototype NPCs in **any order** (Tab lock + **1** per archetype until **`— defeated!`**).
3. After each defeat: verify **`EncounterProgressLabel`** advances **1/3 → 2/3 → completed (3/3)** (may lag one GET behind cast).
4. On completion: **`EncounterCompleteLabel`** lists both grants; **`InventoryLabel`** shows **`scrap_metal_bulk`** and **`contract_handoff_token`** without pressing **I**.
5. **Record inventory quantities** for both grant items (note baseline if bag had prior items from earlier sessions — fresh restart should be empty or known baseline).
6. **Idempotency — Godot restart:** stop Godot run; **F5** again **without** stopping server. Verify encounter HUD still **`completed (3/3)`**; inventory quantities **unchanged** (no duplicate loot).
7. **Idempotency — defeated NPC casts:** lock a defeated NPC; press **1****`CastFeedbackLabel`** shows **`ability_cast_denied: target_defeated`**; encounter + inventory unchanged.
8. Regression: **`GigXpLabel`** shows **`breach`** XP increased per defeat during step 2 (NEO-86); no extra gig XP spike during idempotency steps.
**Notes section:** cross-link [NEO-110](../manual-qa/NEO-110.md) for HUD component detail; [NEO-98](../manual-qa/NEO-98.md) for NPC combat regression; explain why dev **`POST /game/__dev/combat-targets-fixture`** is **out of scope** (clears encounter completion — unsuitable for idempotency).
### 2. Client README (`client/README.md`)
Add **End-to-end encounter clear loop (NEO-111)** section after [Encounter progress + loot HUD (NEO-110)](../../client/README.md#encounter-progress--loot-hud-neo-110), mirroring NEO-98 / NEO-86 capstone sections:
- One-line Epic 5 Slice 3 capstone goal.
- Flow table: fresh restart → boot → defeat ×3 (any order) → progress HUD → loot label → inventory → Godot restart idempotency.
- Cross-links: NEO-106 combat wiring, NEO-108 GET, NEO-110 HUD components, NEO-98 NPC combat.
- Pointer to **`docs/manual-qa/NEO-111.md`**.
### 3. Module / backlog alignment (on land)
When capstone QA passes:
- [E5M3-prototype-backlog.md](E5M3-prototype-backlog.md) — E5M3-12 AC checkboxes.
- [E5_M3_EncounterAndRewardTables.md](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) — module status **Ready**; Slice 3 client capstone note.
- [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) — E5.M3 row: NEO-111 landed; Epic 5 Slice 3 client capstone complete.
- [module_dependency_register.md](../decomposition/modules/module_dependency_register.md) — E5.M3 **Ready** if all stories landed.
### 4. Integration fixes (conditional)
Run capstone QA on `main` (or branch with NEO-110 merged). If any step fails:
- Document failure in plan **Implementation reconciliation**.
- Minimal fix in client or server; add/adjust automated test only when fix is non-trivial.
- Re-run full capstone script before land.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-111-implementation-plan.md` | This plan. |
| `docs/manual-qa/NEO-111.md` | Single-session Godot capstone script with idempotency steps. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/README.md` | **End-to-end encounter clear loop (NEO-111)** section with flow table and cross-links. |
| `docs/plans/E5M3-prototype-backlog.md` | E5M3-12 acceptance checkboxes when capstone lands. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | Module status + capstone landed note. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — NEO-111 capstone complete. |
| `docs/decomposition/modules/module_dependency_register.md` | E5.M3 status **Ready** when slice complete. |
**Conditional (only if capstone QA fails):** client scripts (`main.gd`, `encounter_progress_client.gd`, …) or server encounter path — document paths in **Implementation reconciliation**.
## Tests
| Test file | What it covers |
|-----------|----------------|
| *(none planned at kickoff)* | Capstone is manual Godot verification; NEO-110 GdUnit tests cover encounter-progress GET client. |
**If integration fix required:** add or extend the smallest automated test that locks the fix (GdUnit for client parse/HUD helper; C# AAA for server deny/idempotency). Otherwise manual QA replaces automated coverage per [NEO-98](NEO-98-implementation-plan.md) precedent.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|----------------------|--------|
| **NEO-110 on `main` at capstone run time** | Confirm NEO-110 merged before executing capstone QA; branch may need rebase onto `main`. | **adopted** — NEO-110 merged (PR #149) on branch |
| **Inventory baseline on fresh restart** | Script instructs tester to note pre-grant bag state; fresh restart should yield empty or predictable baseline. | **adopted** |
| **Concurrent GET lag on progress label** | Allow ~1 s after cast before asserting progress step (NEO-110 manual QA note). | **adopted** |
| **Capstone QA scope expansion** | Kickoff was docs-primary; capstone QA may drive minimal integration fixes — document in **Implementation reconciliation**. | **deferred** |

View File

@ -0,0 +1,148 @@
# NEO-112 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-112 |
| **Title** | E7M1-01: Prototype QuestDef catalog + schemas + CI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-112/e7m1-01-prototype-questdef-catalog-schemas-ci |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-01** |
| **Branch** | `NEO-112-e7m1-prototype-questdef-catalog-schemas-ci` |
| **Precursor** | [E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) gather **Ready** · [E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) craft **Ready** · [E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) encounter **Ready** (frozen item/recipe/encounter ids) |
| **Pattern** | [NEO-100](NEO-100-implementation-plan.md) — row JSON Schema + catalog envelope + `scripts/validate_content.py` gate |
| **Blocks** | [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) — server quest catalog load (fail-fast) |
## Kickoff clarifications
**No clarifications needed.** Scope, frozen quest ids, step/objective graph, prerequisite rules, objective kinds, and cross-ref targets are fully specified in [E7M1 kickoff decisions](E7M1-prototype-backlog.md#kickoff-decisions-decomposition-defaults), [E7.M1 freeze table](../decomposition/modules/E7_M1_QuestStateMachine.md#prototype-slice-1-freeze-e7m1-01), and [E7M1-01 backlog entry](E7M1-prototype-backlog.md#e7m1-01--prototype-questdef-catalog--schemas--ci). Catalog envelope and CI constant naming follow NEO-100 / NEO-87 precedent without ambiguity.
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| — | — | — | **No blocking decisions** (see above). |
## Goal, scope, and out-of-scope
**Goal:** Lock content shape and CI validation for **four** frozen `QuestDef` rows before server load (NEO-113+).
**In scope (from Linear + [E7M1-01](E7M1-prototype-backlog.md#e7m1-01--prototype-questdef-catalog--schemas--ci)):**
- `content/schemas/quest-def.schema.json`, `content/schemas/quest-step-def.schema.json`, `content/schemas/quest-objective-def.schema.json`.
- `content/quests/prototype_quests.json` — four rows matching the module doc freeze table.
- `scripts/validate_content.py` — schema validation, duplicate ids, four-quest allowlist, cross-ref **`itemId`** / **`recipeId`** / **`encounterId`** to frozen catalogs, acyclic **`prerequisiteQuestIds`**, objective **`kind`** enum, chain terminal step **`inventory_has_item`** **`contract_handoff_token`** ×1 gate.
- Designer note / CI rules in [E7_M1](../decomposition/modules/E7_M1_QuestStateMachine.md) (CI enforcement line already present) + **CT.M1** PR gate paragraph + `content/README.md` quest row.
- `documentation_and_implementation_alignment.md` E7.M1 row → note NEO-112 catalog when complete.
**Out of scope (from Linear):**
- Server loader, runtime engine, HTTP, Godot (NEO-113+).
- **`IQuestDefinitionRegistry`**, player quest state, objective hooks (E7M1-03+).
- **Client counterpart:** none — server-only content + CI; player-visible work starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123).
## Acceptance criteria checklist
- [x] PR gate validates quest JSON against schema.
- [x] Exactly four prototype quest ids; duplicate `id` fails CI.
- [x] Every objective reference resolves to a frozen catalog id.
- [x] Stable id list documented in module doc freeze box (already present; verify after ship).
## Implementation reconciliation (shipped)
- **Schemas:** [`quest-objective-def.schema.json`](../../content/schemas/quest-objective-def.schema.json), [`quest-step-def.schema.json`](../../content/schemas/quest-step-def.schema.json), [`quest-def.schema.json`](../../content/schemas/quest-def.schema.json).
- **Catalog:** [`prototype_quests.json`](../../content/quests/prototype_quests.json) — four frozen quest rows per E7.M1 freeze table.
- **CI:** `PROTOTYPE_E7M1_QUEST_IDS`, prerequisite acyclic gate, objective cross-ref gates, chain terminal token gate in [`validate_content.py`](../../scripts/validate_content.py); duplicate quest/step/objective id rejection.
- **Docs:** CT.M1 quest paragraph, `content/README.md` quests row + E7M1 paragraph, alignment register E7.M1 row, E7M1-01 backlog checkboxes.
## Technical approach
1. **`quest-objective-def.schema.json`:** Draft 2020-12 object; `additionalProperties: false`. Required: **`id`**, **`kind`**. **`kind`** enum: **`gather_item`**, **`craft_recipe`**, **`encounter_complete`**, **`inventory_has_item`**. Use **`oneOf`** branches keyed by **`kind`**:
- **`gather_item`**: required **`itemId`**, **`quantity`** (integer ≥ 1).
- **`craft_recipe`**: required **`recipeId`**, **`quantity`** (integer ≥ 1).
- **`encounter_complete`**: required **`encounterId`** (no quantity).
- **`inventory_has_item`**: required **`itemId`**, **`quantity`** (integer ≥ 1).
- Id patterns: `^[a-z][a-z0-9_]*$` (same as other catalogs).
2. **`quest-step-def.schema.json`:** Required: **`id`**, **`displayName`**, **`objectives`** (non-empty array of `$ref` quest-objective-def). Reject duplicate objective **`id`** within a step in CI (post-schema).
3. **`quest-def.schema.json`:** Required: **`id`**, **`displayName`**, **`prerequisiteQuestIds`** (array of strings; may be empty), **`steps`** (non-empty array of `$ref` quest-step-def). Reject duplicate step **`id`** within a quest in CI (post-schema).
4. **Catalog file `content/quests/prototype_quests.json`:**
Envelope: **`schemaVersion`: 1**, top-level **`quests`** array. Four rows per freeze table:
| `id` | `displayName` | `prerequisiteQuestIds` | Steps |
|------|---------------|------------------------|-------|
| `prototype_quest_gather_intro` | Intro: Salvage Run | `[]` | 1× **`gather_item`** `scrap_metal_bulk` ×3 |
| `prototype_quest_refine_intro` | Intro: Refine Stock | `[prototype_quest_gather_intro]` | 1× **`craft_recipe`** `refine_scrap_standard` ×1 |
| `prototype_quest_combat_intro` | Intro: Clear the Pocket | `[]` | 1× **`encounter_complete`** `prototype_combat_pocket` |
| `prototype_quest_operator_chain` | Operator Chain | all three intros | 4 steps: gather ×5 → refine → make_field_stim_mk0 → **`inventory_has_item`** token ×1 |
Step/objective **`id`** values are stable content keys (e.g. `gather_intro_step_salvage`, `chain_obj_token`); chosen for readability and future HTTP projection (E7M1-04).
5. **`validate_content.py` constants (keep in sync with E7.M1 module doc + future NEO-113 loader):**
| Constant | Value |
|----------|--------|
| `PROTOTYPE_E7M1_QUEST_IDS` | `{ prototype_quest_gather_intro, prototype_quest_refine_intro, prototype_quest_combat_intro, prototype_quest_operator_chain }` |
| `PROTOTYPE_E7M1_CHAIN_QUEST_ID` | `prototype_quest_operator_chain` |
| `PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID` | `contract_handoff_token` |
6. **`validate_content.py` validators:**
- Add schema paths + dir: `QUEST_DEF_SCHEMA`, `QUEST_STEP_DEF_SCHEMA`, `QUEST_OBJECTIVE_DEF_SCHEMA`, `QUESTS_DIR`.
- Register `$ref` between quest / step / objective schemas in the Draft 2020-12 registry (same pattern as recipe + recipe-io-row).
- **`_validate_quest_catalogs`:** envelope `schemaVersion: 1`, top-level **`quests`** array; validate each row; track `seen_ids`; reject duplicate quest **`id`** across files; build `id_to_row` map.
- **`_prototype_e7m1_quest_gate`:** ids must equal `PROTOTYPE_E7M1_QUEST_IDS`.
- **`_prototype_e7m1_quest_prerequisite_gate`:** every **`prerequisiteQuestIds`** entry must exist in `seen_ids`; graph must be **acyclic** (DFS / Kahn).
- **`_prototype_e7m1_quest_cross_ref_gate`:** per objective — **`itemId`** ∈ `PROTOTYPE_SLICE1_ITEM_IDS`; **`recipeId`** ∈ `PROTOTYPE_SLICE3_RECIPE_IDS`; **`encounterId`** ∈ `PROTOTYPE_E5M3_ENCOUNTER_IDS` (only when present for kind).
- **`_prototype_e7m1_chain_terminal_gate`:** operator chain last step's sole objective is **`inventory_has_item`** with **`itemId`** `contract_handoff_token`, **`quantity`** 1.
- Run quest validation **after** item, recipe, and encounter catalogs (cross-ref dependencies).
- Extend module docstring, startup schema file checks, success summary with quest file/id counts.
7. **Docs:**
- `E7_M1_QuestStateMachine.md` — verify CI enforcement line under freeze table matches shipped gates.
- `CT_M1_ContentValidationPipeline.md` — quest catalog PR gate paragraph.
- `content/README.md` — add `quests/` table row + E7M1 prototype paragraph (four quest ids, cross-ref rules).
- `documentation_and_implementation_alignment.md` — E7.M1 row notes NEO-112 catalog when complete.
- `E7M1-prototype-backlog.md` — E7M1-01 checkboxes when implementation completes.
8. **No server/C#/client changes** in this story.
## Files to add
| Path | Purpose |
|------|---------|
| `content/schemas/quest-objective-def.schema.json` | JSON Schema for a single quest objective row (discriminated by `kind`). |
| `content/schemas/quest-step-def.schema.json` | JSON Schema for a quest step (objectives array). |
| `content/schemas/quest-def.schema.json` | JSON Schema for a single `QuestDef` row (steps + prerequisites). |
| `content/quests/prototype_quests.json` | Prototype four-quest catalog (`schemaVersion` + `quests` array). |
| `docs/plans/NEO-112-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Load/validate quest catalogs; duplicate `id`; E7M1 four-id gate; item/recipe/encounter cross-refs; acyclic prerequisites; chain terminal token gate; success summary. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for quest catalog validation. |
| `content/README.md` | `quests/` path row + E7M1 prototype CI paragraph. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 / NEO-112 status after catalog lands. |
## Tests
| Item | Coverage |
|------|----------|
| **CI / PR gate** | `.github/workflows/pr-gate.yml` runs `python scripts/validate_content.py` — extended script is the primary regression signal. |
| **Manual negative cases** | From repo root after implementation: (1) duplicate quest `id` → non-zero exit; (2) remove frozen quest id → gate error; (3) unknown `itemId` / `recipeId` / `encounterId` in objective → cross-ref error; (4) cyclic `prerequisiteQuestIds` → gate error; (5) unknown prerequisite id → gate error; (6) operator chain terminal step not `inventory_has_item` token ×1 → gate error; (7) invalid `kind` or missing required field per kind → schema error. |
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports quest catalog + existing catalogs OK. |
No dedicated pytest module in-repo today (same as NEO-100 / NEO-87).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| Objective `oneOf` schema complexity vs flat optional fields | **`oneOf` per `kind`** — mirrors encounter `completionCriteria`; catches wrong-field-at-wrong-kind at schema time. | **adopted** |
| Combat intro has no gather prerequisite (parallel onboarding path) | **Ship as freeze table specifies** — chain quest requires all three intros; refine only requires gather intro. | **adopted** |
| Step/objective ids not in freeze table | **Stable descriptive ids in catalog JSON** — required for E7M1-04 HTTP projection; not part of four-quest id gate. | **adopted** |
| `content/README.md` has no quest paragraph yet | **Add paragraph on ship** — unlike NEO-100 (pre-landed), quests row is new. | **adopted** |

View File

@ -0,0 +1,145 @@
# NEO-113 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-113 |
| **Title** | E7M1-02: Server quest catalog load (fail-fast) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-113/e7m1-02-server-quest-catalog-load-fail-fast |
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-02** |
| **Branch** | `NEO-113-e7m1-server-quest-catalog-load-fail-fast` |
| **Precursor** | [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) — quest schemas, `prototype_quests.json`, CI gates (**landed on `main`**) |
| **Pattern** | [NEO-101](NEO-101-implementation-plan.md) — row JSON Schema + catalog envelope + in-process C# gates mirroring `scripts/validate_content.py` |
| **Blocks** | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) — injectable quest definition registry + DI |
| **Client counterpart** | None — server-only catalog load; player-visible work starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Test scope** | Loader unit tests only, or also `WebApplicationFactory` host boot failure / DI resolve tests? | **Both** — mirror [NEO-101](NEO-101-implementation-plan.md); Linear AC “host fails on invalid quest JSON” is stronger with an integration boot test. | **User:** loader + host boot tests. |
| **NEO-113 vs NEO-114 split** | Ship `IQuestDefinitionRegistry` on this story? | **Strict split** — E7M1-02: loader + catalog singleton + eager fail-fast boot; [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) adds injectable registry (E7M1-03). Same pattern as NEO-101/102. | **Adopted** — backlog out-of-scope; no separate question needed. |
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` E7M1 gates (NEO-51/101 precedent; no Python on server images). | **Adopted** |
| **Cross-ref at load** | Validate objective `itemId` / `recipeId` / `encounterId` against loaded catalogs? | **Yes** — quest loader takes `knownItemIds`, `knownRecipeIds`, `knownEncounterIds` from resolved singletons; load **after** item, recipe, and encounter catalogs (same order as CI `main()`). | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** Disk → host: startup load of `content/quests/*_quests.json` with CI-parity validation; process refuses to listen when catalogs are missing, malformed, or fail prototype E7M1 gates.
**In scope (from Linear + [E7M1-02](E7M1-prototype-backlog.md#e7m1-02--server-quest-catalog-load-fail-fast)):**
- Loader + catalog types under `server/NeonSprawl.Server/Game/Quests/`.
- Fail-fast on malformed files, duplicate ids (quest / step / objective), allowlist mismatch with CI, broken cross-refs (items, recipes, encounters), acyclic prerequisite graph, chain terminal token gate.
- Unit tests (AAA) for loader happy path and failure modes; host boot tests (AAA).
- `server/README.md` section for quest catalog.
**Out of scope (from Linear + backlog):**
- Injectable registry interface (**NEO-114** / E7M1-03).
- HTTP projection, player quest state, runtime state machine (E7M1-04+).
- Godot client.
## Acceptance criteria checklist
- [x] Host fails startup on invalid quest JSON (mirror CI rules).
- [x] Loader tests cover allowlist + cross-ref failures (and host boot per kickoff).
## Implementation reconciliation (shipped)
- **Loaders:** `QuestDefinitionCatalogLoader` + `PrototypeE7M1QuestCatalogRules` under `Game/Quests/` with CI-parity gates.
- **Catalog:** `QuestDefinitionCatalog` singleton; eager resolve in `Program.cs` after encounter catalogs.
- **DI:** `AddQuestDefinitionCatalog` — loads after item, recipe, and encounter catalogs for cross-refs.
- **Config:** `ContentPathsOptions` quest path keys; `InMemoryWebApplicationFactory` pins repo `content/quests`.
- **Tests:** 20 AAA tests in `QuestDefinitionCatalogLoaderTests` (loader + host boot, incl. row schema violation + chain terminal quantity).
- **Docs:** `server/README.md` quest catalog section; alignment register E7.M1 row updated; `E7_M1_QuestStateMachine.md` Status + server-load line.
- **Bruno:** `bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru` (boot smoke; no quest HTTP in this story).
## Technical approach
1. **`Game/Quests/` load-time DTOs** — nested rows mirroring catalog JSON:
- `QuestObjectiveDefRow``Id`, `Kind`, optional `ItemId`, `Quantity`, `RecipeId`, `EncounterId` (populated per kind).
- `QuestStepDefRow``Id`, `DisplayName`, `Objectives`.
- `QuestDefRow``Id`, `DisplayName`, `PrerequisiteQuestIds`, `Steps`.
2. **`QuestDefinitionCatalog`** — immutable snapshot: directory path, `ById`, `DistinctQuestCount`, `CatalogJsonFileCount`; `TryGetQuest(id, out QuestDefRow?)`.
3. **`QuestDefinitionCatalogLoader.Load`** — parameters: `questsDirectory`, `questDefSchemaPath`, `questStepDefSchemaPath`, `questObjectiveDefSchemaPath`, `knownItemIds`, `knownRecipeIds`, `knownEncounterIds`, `logger`:
- Enumerate `*_quests.json` (top-level only), ordered by path.
- Per file: parse JSON; require `schemaVersion == 1` and top-level `quests` array.
- Register `quest-objective-def.schema.json`, `quest-step-def.schema.json`, then `quest-def.schema.json` in `SchemaRegistry.Global` before row validation (recipe I/O row pattern).
- Per row: JsonSchema.Net validation; track quest `id`; reject duplicate quest `id` across files.
- Post-schema: reject duplicate step `id` within a quest; reject duplicate objective `id` within a quest (across all steps — same as CI).
- Run **`PrototypeE7M1QuestCatalogRules`** gates (sync comment → `PROTOTYPE_E7M1_*` in `validate_content.py`):
- Four-id allowlist gate.
- Prerequisite known-id + acyclic DFS gate.
- Objective cross-ref gate (`gather_item` / `inventory_has_item` → item ids; `craft_recipe` → recipe ids; `encounter_complete` → encounter ids).
- Operator chain terminal step: sole objective `inventory_has_item` **`contract_handoff_token`** ×1.
- Throw **`InvalidOperationException`** with aggregated sorted messages prefixed `Quest catalog validation failed:`.
- Information log: absolute quests directory, quest count, JSON file count.
4. **`PrototypeE7M1QuestCatalogRules`** — frozen constants + gate helpers:
- `ExpectedQuestIds` — sync → `PROTOTYPE_E7M1_QUEST_IDS`.
- `ChainQuestId`, `ChainTerminalItemId` — sync → `PROTOTYPE_E7M1_CHAIN_QUEST_ID`, `PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID`.
- Cross-ref sets reuse `PrototypeSlice1ItemCatalogRules.ExpectedItemIds`, `PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds`, `PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds` where applicable (or pass-through from loaded catalogs — loader receives live id sets from DI).
5. **Path resolution**`QuestCatalogPathResolution`: `TryDiscoverQuestsDirectory`, `ResolveQuestsDirectory`, `ResolveQuestDefSchemaPath`, `ResolveQuestStepDefSchemaPath`, `ResolveQuestObjectiveDefSchemaPath` (ancestor walk from `AppContext.BaseDirectory` for `content/quests`; schema defaults under sibling `content/schemas/`).
6. **`ContentPathsOptions`** — add `QuestsDirectory`, `QuestDefSchemaPath`, `QuestStepDefSchemaPath`, `QuestObjectiveDefSchemaPath`.
7. **`QuestCatalogServiceCollectionExtensions.AddQuestDefinitionCatalog`**:
- Factory for `QuestDefinitionCatalog` depends on `ItemDefinitionCatalog`, `RecipeDefinitionCatalog`, `EncounterDefinitionCatalog` (known id sets from `.ById.Keys`).
- **No** `IQuestDefinitionRegistry` yet (NEO-114).
8. **`Program.cs`** — register extension after encounter catalogs; eager-resolve `QuestDefinitionCatalog` before `Run()`.
9. **`InMemoryWebApplicationFactory`** — pin `Content:QuestsDirectory` to repo path so existing tests keep booting.
10. **`appsettings.json`** — optional empty `Content:QuestsDirectory` + schema override keys for documentation.
11. **Tests** — temp-dir fixtures copying repo schemas; valid JSON from [prototype_quests.json](../../content/quests/prototype_quests.json); negative cases for schema violation, duplicate quest/step/objective id, wrong allowlist id, unknown prerequisite, cyclic prerequisites, unknown item/recipe/encounter id, wrong chain terminal objective, `schemaVersion` ≠ 1, missing schema file; host resolve via DI + `/health`; host fail with empty quests dir.
12. **Docs**`server/README.md` quest catalog section (config keys, discovery, fail-fast, load order); `documentation_and_implementation_alignment.md` E7.M1 row note server load landed when complete.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs` | Load-time DTO for a single quest objective row. |
| `server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs` | Load-time DTO for a quest step (objectives array). |
| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Load-time DTO for a single `QuestDef` row. |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs` | In-memory quest catalog + `TryGetQuest`. |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Disk I/O, schema validation, duplicate id gates, E7M1 rules, logging. |
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs` | Directory/schema discovery for quests. |
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs` | Frozen quest ids + prerequisite / cross-ref / chain terminal gates (sync → Python). |
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | `AddQuestDefinitionCatalog` DI registration. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Repo `content/quests` + schema discovery for tests. |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | AAA loader + host boot tests for quest catalog. |
| `docs/plans/NEO-113-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add quest directory and schema path options under `Content` section. |
| `server/NeonSprawl.Server/Program.cs` | Register quest catalog; eager-resolve after build. |
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:QuestsDirectory` / schema override keys for documentation. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin quest content path so test host boots with repo catalog. |
| `server/README.md` | New quest catalog section (config, discovery, fail-fast, load order). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-113 server load when complete. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | **Unit:** valid prototype quest catalog loads (4 quests); `quests` not array; schema violation; duplicate quest `id`; duplicate step `id` within quest; duplicate objective `id` within quest; wrong/missing E7M1 quest id; unknown prerequisite id; cyclic `prerequisiteQuestIds`; unknown `itemId` / `recipeId` / `encounterId` in objective; wrong chain terminal objective (kind / item / quantity); `schemaVersion` ≠ 1; missing schema file. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI resolve `QuestDefinitionCatalog` (quest `prototype_quest_gather_intro`); `WebApplicationFactory` with empty quests dir fails startup with actionable message. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Constants drift vs Python** | Comment on `PrototypeE7M1QuestCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E7M1_*` constants and gate functions. | **adopted** |
| **Load order in DI** | Register quest catalog factory after item, recipe, and encounter singletons; pass `.ById.Keys` as known id sets. | **adopted** |
| **Test cwd / CI** | `InMemoryWebApplicationFactory` must set `Content:QuestsDirectory` explicitly. | **adopted** |
| **Three-schema $ref registration** | Register objective → step → def schemas in `SchemaRegistry.Global` before evaluating quest rows (same as CI `_quest_def_validator`). | **adopted** |
| **Objective id uniqueness scope** | Duplicate objective ids rejected **within quest** (all steps), matching NEO-112 CI gate — not per-step. | **adopted** |

View File

@ -0,0 +1,59 @@
# Code review — NEO-103 (E5M3-04)
**Date:** 2026-05-31
**Scope:** Branch `NEO-103-get-world-encounter-definitions` vs `fb5137c` (merge-base on `main`) — commits `62e6bfd``935e8d5`
**Base:** `fb5137c` (main at branch point, post NEO-102 merge)
## Verdict
**Approve with nits**
## Summary
NEO-103 adds **`GET /game/world/encounter-definitions`**, mirroring the established NEO-78 / NEO-60 world-definition pattern: versioned envelope (`schemaVersion` **1**, **`encounters[]`**), registry-backed projection ( **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`**, not catalog types), nested **`completionCriteria`** and **`rewardTable`** summary, id-order from **`GetDefinitionsInIdOrder()`**, and fail-fast **`InvalidOperationException`** when a reward-table cross-ref is missing at HTTP time. One AAA integration test, Bruno smoke with four assertion blocks, `Program.cs` wiring after resource-node definitions, and docs (plan, backlog, alignment register, module register, `server/README.md`) are included. Risk is low: read-only server route with no player state; client wiring correctly deferred to NEO-110 / NEO-111.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-103-implementation-plan.md` | **Matches** — nested `rewardTable`, nested `completionCriteria`, registry injection, fail-fast reward-table guard, Bruno + AAA test, README; acceptance checklist checked; reconciliation section accurate. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-04) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches****Implementation snapshot** HTTP bullet added; Summary **Status** row marks **E5M3-04 / NEO-103 HTTP read landed** (review follow-up). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-103** HTTP read; runtime **NEO-104+** still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-103 landed** + README + Bruno. |
| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 / NEO-111. |
| `server/README.md` | **Matches** — encounter-definitions section documents route, fields, curl, plan + Bruno links. |
## Blocking issues
None.
## Suggestions
1. ~~**Sync `E5_M3_EncounterAndRewardTables.md` Summary Status** — Update the **Status** table row to mark **E5M3-04 / NEO-103 HTTP read landed** (and point forward to **E5M3-05 / NEO-104**), mirroring how **NEO-101** and **NEO-102** landed markers appear in the same row. The implementation snapshot and register are already updated; only the Summary line lags.~~ **Done.** Summary **Status** row updated.
2. ~~**Optional: assert `fixedGrants` order in C# test** — Bruno asserts the exact grant sequence (`scrap_metal_bulk` then `contract_handoff_token`); the integration test checks count and per-item quantities via `Single` but not array order. Adding an ordered assertion (or a frozen grants constant) would lock catalog-order regressions in `dotnet test` without relying on Bruno alone (NEO-53 precedent).~~ **Done.** `FrozenPrototypeGrantsInOrder` + indexed assertions in `EncounterDefinitionsWorldApiTests`.
## Nits
- ~~Nit: Bruno **`encounters are ascending by id (ordinal)`** uses a hand-rolled string compare; peer requests (e.g. item-definitions) document **StringComparer.Ordinal** in a comment. Harmless for ASCII snake_case ids; copy the comment for parity if touching the file.~~ **Done.** Ordinal comment added to Bruno id-order test.
- Nit: With a single prototype encounter, the id-order Bruno test and C# **`Assert.Single`** are structurally redundant today; they become valuable when the roster expands—no change required now.
## Verification
```bash
# NEO-103-focused (1 test — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterDefinitionsWorldApi"
# Broader encounter slice (catalog + registries + HTTP)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
# Bruno (full collection — new folder auto-included)
cd bruno/neon-sprawl-server && npx --yes @usebruno/cli@3.3.0 run --env Local --sandbox=developer --bail
```
**Local run:** 1/1 `EncounterDefinitionsWorldApi` test passed.

View File

@ -0,0 +1,60 @@
# Code review — NEO-104 (E5M3-05)
**Date:** 2026-05-31
**Scope:** Branch `NEO-104-encounter-progress-completion-stores` vs `aa81914` (merge-base on `main`) — commits `727ce47``aaccbbb`
**Base:** `aa81914` (main at branch point, post NEO-103 merge)
## Verdict
**Approve with nits**
## Summary
NEO-104 adds server-owned per-player encounter **activation**, **defeat progress** (order-independent subset of `requiredNpcInstanceIds`), and an **idempotent completion flag** at the store layer, plus static **`EncounterProgressOperations`** that auto-resolve encounters from NPC ids via **`IEncounterDefinitionRegistry`**. Implementation mirrors established patterns (`InMemoryResourceNodeInstanceStore` per-key locks, `GatherOperations` / `AggroOperations` static orchestration): split **`IEncounterProgressStore`** / **`IEncounterCompletionStore`**, `EncounterProgressIds` normalization, DI in **`AddEncounterAndRewardCatalogs`**, telemetry hook comment on activation, and ten new AAA unit tests plus a host DI smoke in **`EncounterDefinitionRegistryTests`**. Docs (plan, backlog, alignment register, module register, E5.M3 snapshot, `server/README.md`) are updated. Combat cast and HTTP read are correctly deferred to NEO-106 / NEO-108; no client counterpart required. Risk is low: in-memory stores with no player-visible surface until NEO-108 / NEO-110.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-104-implementation-plan.md` | **Matches** — split stores, operations API, in-memory DI, deferred combat/HTTP, kickoff decisions recorded, acceptance checklist checked, reconciliation section accurate. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-05) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Summary **Status** marks **E5M3-05 / NEO-104** landed; **Implementation snapshot** progress bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-104** stores; **NEO-105+** grants/combat/HTTP still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-104** store landing; **NEO-105+** outstanding. |
| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 via NEO-108. |
| `server/README.md` | **Matches** — encounter progress section documents stores, operations, and deferred NEO-106/108 wiring. |
## Blocking issues
None.
## Suggestions
1. ~~**Plan file list vs shipped diff** — The plan lists **`InMemoryWebApplicationFactory.cs`** and **`Program.cs`** as files to modify; neither changed. This is acceptable: stores register in the existing **`AddEncounterAndRewardCatalogs`** extension (already invoked at startup), and encounter stores are always in-memory (no Postgres swap needed in the test factory, unlike position/inventory stores). Consider a one-line note in the plan **Implementation reconciliation** that factory/Program edits were N/A.~~ **Done.** Note added to plan **Implementation reconciliation**.
2. ~~**Completion guard on `TryActivate`** — The plan describes **`TryActivate`** returning `false` when the completion store marks the encounter done. Gating lives only in **`EncounterProgressOperations.TryActivateOnFirstEngagement`**; a direct **`IEncounterProgressStore.TryActivate`** call after completion would still create a started row. Acceptable while NEO-106/105 route exclusively through operations; add a brief XML note on **`IEncounterProgressStore.TryActivate`** that completion gating is the caller's responsibility if direct store use is anticipated.~~ **Done.** XML on **`IEncounterProgressStore.TryActivate`**.
3. ~~**Optional operations coverage** — Store and core happy-path operations are well covered. Consider adding AAA tests for: **`TryActivateOnFirstEngagement`** returns `false` when encounter is already completed; resolve/activate returns `false` for an NPC id not in any encounter definition.~~ **Done.** Two new tests in **`EncounterProgressOperationsTests`** plus mixed-case **`IsAllRequiredTargetsDefeated`** coverage.
4. ~~**`IsAllRequiredTargetsDefeated` id normalization** — The method passes **`encounterId`** to case-sensitive **`TryGetDefinition`**. NEO-102 precedent documents that only **`TryNormalizeKnown`** lowercases. Internal callers today use catalog **`row.Id`** (lowercase); NEO-108 HTTP should normalize before calling — optionally harden now with **`TryNormalizeKnown`** or **`EncounterProgressIds.NormalizeEncounterId`** + lookup for defense in depth.~~ **Done.** **`TryNormalizeKnown`** before lookup and progress read.
## Nits
- ~~Nit: **`Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds`** — **`Assert.NotNull`** on values from **`GetRequiredService`** is redundant (missing registration throws). Minor AAA nit: DI resolution fits **Arrange**; **Act** could be only **`TryActivate`** / **`TryMarkCompleted`**.~~ **Done.**
- ~~Nit: **`InMemoryEncounterCompletionStore.IsCompleted`** reads **`ConcurrentDictionary`** without the per-key lock used by **`TryMarkCompleted`** / **`TryGetCompletedAt`**. Benign for prototype bool checks; align with lock if strict read-your-writes under concurrency matters later.~~ **Done.** **`IsCompleted`** uses per-key lock.
## Verification
```bash
# NEO-104-focused (10 new tests + host DI smoke — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterProgressOperations|FullyQualifiedName~InMemoryEncounterProgressStore|FullyQualifiedName~InMemoryEncounterCompletionStore|Host_ShouldResolveEncounterProgressStoresFromDi"
# Broader encounter slice (catalog + registries + HTTP + progress)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
```
**Local run:** 52/52 `Encounters` filter tests passed.

View File

@ -0,0 +1,60 @@
# Code review — NEO-105 (E5M3-06)
**Date:** 2026-05-31
**Scope:** Branch `NEO-105-encounter-completion-inventory-reward-apply` vs `719a14f` (merge-base on `main`) — commits `b5306aa``009fedf`
**Base:** `719a14f` (main at branch point, post NEO-104 merge)
**Follow-up:** Review suggestions and nits addressed in commit after `009fedf`.
## Verdict
**Approve with nits**
## Summary
NEO-105 adds **`EncounterCompletionOperations.TryCompleteAndGrant`**, which resolves an encounter and reward table, pre-flights inventory via **`PlayerInventoryOperations.TrySimulateAddStack`**, applies **`fixedGrants`** sequentially with compensating rollback on partial failure or failed **`TryMarkCompleted`**, and returns an internal **`EncounterCompleteEvent`** payload on success (persistence deferred to NEO-107). Supporting types mirror established patterns (`GatherOperations` / `CraftOperations`): **`EncounterCompletionResult`**, **`EncounterCompletionReasonCodes`**, **`EncounterGrantApplied`**. Six AAA unit tests cover grant-once, idempotent replay, `not_ready`, bag-full fail-closed, mark-fail rollback, and unknown encounter. Docs (plan, backlog, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. Combat wiring and HTTP read remain correctly deferred to NEO-106 / NEO-108; client counterpart NEO-110 is out of scope. Risk is low: static ops with no new DI or player-visible surface until NEO-106.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-105-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; combat/HTTP/client deferred. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-06) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status marks **E5M3-06 / NEO-105** landed; implementation snapshot bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-105** completion grants; NEO-106+ combat/HTTP still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-105** completion ops landed. |
| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. |
| `server/README.md` | **Matches** — encounter completion section documents ops, reason codes, frozen grants, and deferrals. |
## Blocking issues
None.
## Suggestions
1. ~~**Apply-phase partial rollback test** — The plan test table mentions compensating rollback when the bag cannot fit both grants, but **`FillBag`** fills every slot so pre-flight denies before any apply; the mark-fail test covers rollback after both grants succeed. Consider an AAA test where pre-flight passes but the second **`TryAddStack`** fails during apply (e.g. stub inventory store that allows simulate/add for the first grant only) to exercise **`CompensatingRemoveGrants`** on the apply path, not only the mark-complete race path.~~ **Done.** `TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails` uses **`InventoryStoreFullAfterFirstGrant`** to fill the bag after the first apply so pre-flight still passes.
2. ~~**Denial telemetry hook comment** — **`EncounterProgressOperations`** documents an E9.M1 hook on activation; **`CraftOperations`** / **`GatherOperations`** comment deny sites. **`EncounterCompletionOperations`** only has the E7.M2 success TODO. A brief `// TODO(E9.M1): catalog emit — encounter_complete_denied` (or similar) on **`Deny`** would align with NEO-109 without adding runtime code.~~ **Done.** `encounter_complete_denied` hook comment added on **`Deny`**.
3. ~~**`unknown_reward_table` for empty `FixedGrants`** — Empty grants return **`unknown_reward_table`** (also documented in README). Prototype catalog gates prevent this at startup; if a future table legitimately has zero fixed grants, a dedicated reason code may be clearer. Fine for prototype; note for catalog expansion.~~ **Addressed.** Inline comment on empty-grants branch; dedicated reason deferred until zero-grant tables ship.
## Nits
- ~~Nit: **`MapInventoryReason`** default branch maps unrecognized inventory reason codes to **`inventory_full`**. Safe given startup validation; passthrough of the raw code (or a dedicated **`inventory_denied`**) would aid debugging if invalid catalog rows slip through in tests.~~ **Done.** Unrecognized codes passthrough; **`null`** still maps to **`inventory_full`**.
- ~~Nit: **`TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice`** — first successful completion is correctly in **Arrange**; consider asserting **`first.ReasonCode`** is null and **`second.GrantsApplied`** is empty in one block for symmetry with the success test (already partially covered).~~ **Done.** Assert **`first.ReasonCode`** null and **`first.GrantsApplied.Count`** == 2 alongside second-call denies.
## Verification
```bash
# NEO-105-focused (6 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterCompletionOperations"
# Broader encounter slice (62 tests — passed locally; one flaky failure observed on first full run, passed on retry)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
```
**Local run:** 6/6 `EncounterCompletionOperations` tests passed; 62/62 `Encounters` filter tests passed on retry.

View File

@ -0,0 +1,58 @@
# Code review — NEO-106 (E5M3-07)
**Date:** 2026-05-31
**Scope:** Branch `NEO-106-wire-combat-defeat-encounter-progress-completion` vs `7656fde` (merge-base on `main`) — commits `fc09808``8306f92`
**Base:** `7656fde` (main at branch point, post NEO-105 merge)
## Verdict
**Approve with nits**
## Summary
NEO-106 adds **`EncounterCombatWiring.TryProcessCastOutcome`**, a static orchestration helper (mirroring **`CombatDefeatGigXpGrant`**) that activates encounter progress on first damaging hit, marks defeats on lethal hits, and invokes **`EncounterCompletionOperations.TryCompleteAndGrant`** when all required prototype NPCs are defeated. **`AbilityCastApi`** injects encounter/inventory dependencies and calls the helper on every successful combat resolve, preserving the frozen call order: aggro → cooldown → NPC runtime stop → gig XP grant → encounter wiring. Six AAA unit tests and three in-proc integration tests cover activation, partial progress, full completion loot, gig XP preservation (**75** breach XP after three defeats), and idempotent replay on already-completed encounters. Docs (plan, backlog, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. HTTP progress read and client counterpart remain correctly deferred to NEO-108 / NEO-110. Risk is low: best-effort wiring with no cast JSON changes; inventory deny fail-closed behavior inherited from NEO-105.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-106-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; frozen call order and cast→encounter flow table reflected in code. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-07) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status marks **E5M3-07 / NEO-106** landed; implementation snapshot bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-106** combat wiring; NEO-108+ HTTP still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-106** wiring landed. |
| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. |
| `server/README.md` | **Matches** — new **Encounter combat wiring (NEO-106)** section is accurate; encounter progress section defers HTTP read to NEO-108 only. |
## Blocking issues
None.
## Suggestions
1. ~~**Unit test for 2/3 defeated** — Plan test table lists “completion not called when only 2/3 defeated.” Integration test **`PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated`** covers this at HTTP level; a wiring-level AAA test (two **`DefeatNpc`** calls, third **`TryProcessCastOutcome`** with `targetDefeated: false`) would mirror the plan table and isolate completion gating without HTTP.~~ **Done.** `TryProcessCastOutcome_ShouldNotComplete_WhenOnlyTwoTargetsDefeatedAndThirdHitNonLethal`.
2. ~~**Inventory-full on third defeat (wiring path)** — README and plan document fail-closed retry semantics; NEO-105 ops tests cover grant deny. Consider a wiring or integration test that fills the bag before the third lethal cast and asserts progress all-defeated but **`IsCompleted`** false and zero loot delta — exercises the documented retry path end-to-end.~~ **Done.** `TryProcessCastOutcome_ShouldRetainProgressWithoutCompletion_WhenBagFullOnThirdDefeat`.
3. ~~**`encounter_complete` success hook comment** — **`EncounterCompletionOperations`** has NEO-109 deny hook + E7.M2 success TODO; **`EncounterCombatWiring`** logs completion deny at Debug. A brief `// TODO(E9.M1): catalog emit — encounter_complete` on the success branch (or in **`TryCompleteAndGrant`**) would align with NEO-109 scope without runtime code.~~ **Done.** Hook comments on **`EncounterCombatWiring`** success path and **`EncounterCompletionOperations.TryCompleteAndGrant`**.
## Nits
- ~~Nit: **`server/README.md`** encounter progress section (line ~154) still reads “Combat cast wiring … land in NEO-106 / NEO-108” — combat wiring is landed; reword to “HTTP progress read lands in NEO-108” (the next line already links the NEO-106 section).~~ **Done.**
- ~~Nit: **`PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated`** reads **`inventoryAfter`** and resolves **`completionStore`** after the defeat loop (still under **Act**). Moving those reads into **Assert** would tighten AAA; behavior is unchanged.~~ **Done.** Inventory GET and completion store resolve moved to **Assert** in both encounter-loot integration tests.
## Verification
```bash
# NEO-106-focused (11 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterCombatWiring|FullyQualifiedName~PostAbilityCast_ShouldGrantEncounterLoot|FullyQualifiedName~PostAbilityCast_ShouldNotGrantEncounterLoot|FullyQualifiedName~PostAbilityCast_ShouldGrantGigXpPerDefeat"
# Broader encounter slice (71 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
```
**Local run:** 11/11 NEO-106-focused tests passed; 71/71 `Encounters` filter tests passed.

View File

@ -0,0 +1,56 @@
# Code review — NEO-107 (E5M3-09)
**Date:** 2026-05-31
**Scope:** Branch `NEO-107-encounter-complete-event-e7m2-hook` vs `856dc8d` (merge-base on `main`) — commits `986cf55``555c323`
**Base:** `856dc8d` (main at branch point, post NEO-106 merge)
## Verdict
**Approve with nits**
## Summary
NEO-107 adds **`IEncounterCompleteEventStore`** and **`InMemoryEncounterCompleteEventStore`** — a dedicated, idempotent per-player+encounter record for **`EncounterCompleteEvent`**, mirroring the NEO-104 split-store pattern (ConcurrentDictionary + per-key locks). **`EncounterCompletionOperations.TryCompleteAndGrant`** records the event immediately after a successful **`TryMarkCompleted`**, with expanded E7.M2 **`reward_delivery`** hook comments and no runtime router behavior. The store is registered in **`AddEncounterAndRewardCatalogs`** and threaded through **`EncounterCombatWiring`** → **`AbilityCastApi`**. Tests cover store idempotency, all completion deny paths (store stays empty), wiring third-defeat success, and host DI resolution. Docs (plan, backlog E5M3-09, E5.M3 snapshot, E7.M2 anchor, alignment register, module register, `server/README.md`) are updated. HTTP projection and Godot HUD remain correctly deferred to NEO-108 / NEO-110. Risk is low: append-only record on an already-idempotent completion path; **`TryRecord`** false-after-fresh-mark is documented as benign idempotent success.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-107-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; frozen completion→event flow table reflected in code and tests. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-09) | **Matches** — acceptance criteria checked; landed note cites plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — implementation snapshot bullet for NEO-107 added; summary **Status** row includes **E5M3-09 / NEO-107 landed**; NEO-105 bullet cross-links NEO-107 persistence (review follow-up). |
| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — NEO-107 producer hook anchor added with correct consumer contract vocabulary. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-107** event record. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-107** event store landed. |
| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110** via NEO-108; no prototype slice completion claim. |
| `server/README.md` | **Matches** — new **Encounter complete event record (NEO-107)** section is accurate; NEO-105 section updated to reflect persistence. |
## Blocking issues
None.
## Suggestions
1. ~~**E5.M3 summary Status row** — Update the module summary table (line 10) to include **E5M3-09 / NEO-107** alongside the other landed slices so the header matches the implementation snapshot bullets below.~~ **Done.** Status row updated in `E5_M3_EncounterAndRewardTables.md`.
2. ~~**Stale NEO-105 deferral line** — In `E5_M3_EncounterAndRewardTables.md`, the NEO-105 bullet still reads “event persistence deferred to NEO-107.” Reword to point at the NEO-107 snapshot (or drop the deferral phrase) now that persistence is landed.~~ **Done.** NEO-105 bullet now cross-links **`IEncounterCompleteEventStore`** (NEO-107).
3. ~~**Empty encounter id fail-closed test** — Plan test table lists “empty key / invalid ids fail closed.” `InMemoryEncounterCompleteEventStoreTests` covers whitespace **`PlayerId`** only; a symmetric case with empty **`EncounterId`** would close the gap (store layer only).~~ **Done.** Added `TryRecord_ShouldFailClosed_WhenEncounterIdIsEmpty`; split empty **`PlayerId`** into its own test.
## Nits
- ~~Nit: **`InMemoryEncounterCompleteEventStoreTests`** — both test methods call **`TryGet`** under **Act**; move verification reads to **Assert** for full AAA per `csharp-style.md`.~~ **Done.** **`TryGet`** moved to **Assert** in all store tests.
- Nit: **`EncounterDefinitionRegistryTests`** resolves **`IEncounterCompleteEventStore`** in **Arrange** but only asserts **`IsType<InMemoryEncounterCompleteEventStore>`** — sufficient for DI smoke; no functional **`TryRecord`** assertion needed unless you want parity with progress/completion store tests.
## Verification
```bash
# NEO-107-focused (73 tests — passed locally)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters"
# CI parity
python3 scripts/validate_content.py
```
**Local run:** 73/73 `Encounters` filter tests passed.

View File

@ -0,0 +1,62 @@
# Code review — NEO-108 (E5M3-08)
**Date:** 2026-05-31
**Scope:** Branch `NEO-108-get-encounter-progress-wire-dtos-bruno` vs `51e2007` (merge-base on `main`) — commits `1601858``07ebb9e`
**Base:** `51e2007` (main at branch point, post NEO-107 merge)
## Verdict
**Approve with nits**
## Summary
NEO-108 adds **`GET /game/players/{id}/encounter-progress`** — a versioned read model projecting catalog encounters from **`IEncounterDefinitionRegistry`**, **`IEncounterProgressStore`**, **`IEncounterCompletionStore`**, and **`IEncounterCompleteEventStore`**, with the same **`IPositionStateStore`** 404 gate as gig/skill progression. **`EncounterProgressApi.BuildSnapshot`** is internal and testable; state derivation (`inactive` / `active` / `completed`), ordinal-sorted **`defeatedTargetIds`**, and commit-time **`rewardGrantSummary`** match the kickoff plan. Five AAA integration tests cover 404, inactive default, 1- and 2-defeat active rows, and completed + grant summary; Bruno adds inactive smoke and a three-NPC defeat spine with intermediate GET assertions and idempotent second GET. Docs (plan, backlog E5M3-08, E5.M3 snapshot, alignment register, module register, `server/README.md`) are updated. Godot poll client remains correctly deferred to **NEO-110**. Risk is low: read-only projection with fail-fast invariant checks on completed rows.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-108-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate; frozen row progression table reflected in code, tests, and Bruno. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-08) | **Matches** — acceptance criteria checked; landed note cites plan; client counterpart **NEO-110** called out. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Status row includes **E5M3-08 / NEO-108**; implementation snapshot bullet added with README + Bruno links. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-108** per-player GET. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-108** GET landed. |
| Full-stack epic decomposition | **N/A** — plan explicitly defers client counterpart to **NEO-110**; no prototype slice completion claim. |
| `server/README.md` | **Matches** — new **Per-player encounter progress (NEO-108)** section; NEO-106 section cross-links GET for loot verification. |
## Blocking issues
None.
## Suggestions
1. ~~**Invariant violation tests for `BuildSnapshot`** — Plan adopts fail-fast **`InvalidOperationException`** when **`completed`** but **`completedAt`** or **`EncounterCompleteEvent`** is missing. No direct unit test exercises these paths (would require a small fake/mocked store pair). A focused **`BuildSnapshot`** test would lock the contract without a full defeat spine.~~ **Done.** `BuildSnapshot_ShouldThrow_WhenCompletedButCompleteEventMissing` + `BuildSnapshot_ShouldThrow_WhenCompletedButCompletedAtMissing` (`CompletedWithoutTimestampStore` fake).
2. ~~**Idempotent second GET in C# tests** — Bruno **`Get encounter progress after defeat spine.bru`** asserts a second GET stays **`completed`** with unchanged grant summary; the C# suite stops after one GET on the completed case. A second **`GetAsync`** in the completed test (or a dedicated test) would mirror Bruno at the integration layer.~~ **Done.** Second **`GetAsync`** in completed test asserts stable state, **`completedAt`**, and grant summary.
3. ~~**Whitespace / empty player id 404** — Route trims **`id`** and returns 404 when empty; only **`missing-player`** is tested. A **`/game/players/%20/encounter-progress`** (or `""` after trim) case would match the documented gate explicitly (same gap exists on gig progression — low priority).~~ **Done.** `GetEncounterProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly`.
## Nits
- ~~Nit: **`MapRow`** calls **`progressStore.TryGetProgress`** twice on the **`active`** path (once for **`defeatedTargetIds`**, again for **`Started`**). A single **`TryGet`** with reused **`progress`** variable would avoid the duplicate lookup (prototype catalog size = 1; cosmetic only).~~ **Done.**
- Nit: **`EncounterProgressApiTests`** defeat helper binds hotbar **slot 0**; Bruno spine uses **slot 3** per plan (cooldown isolation). Both are fine in isolation; no change required unless slot 0 conflicts emerge in combined Bruno runs.
- ~~Nit: **`MapGrantSummary`** preserves reward-table grant order (scrap then token). C# completed test asserts quantities via **`Single(g => g.ItemId == …)`** (order-agnostic); Bruno asserts array order. Document or test order explicitly if clients will diff arrays.~~ **Done.** XML on **`MapGrantSummary`**; completed test asserts full grant array order (matches Bruno).
## Verification
```bash
# NEO-108-focused (5 tests — passed locally)
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~EncounterProgressApiTests"
# Encounters module parity (79 tests — passed locally)
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~Encounters"
# Bruno (server running on baseUrl)
# bruno/neon-sprawl-server/encounter-progress/
```
**Local run:** 5/5 `EncounterProgressApiTests` passed; 79/79 `Encounters` filter passed.

View File

@ -0,0 +1,59 @@
# Code review — NEO-109 (E5M3-10)
**Date:** 2026-05-31
**Scope:** Branch `NEO-109-slice-3-encounter-telemetry-hook-sites` vs `4aa7665` (merge-base on `main`) — commits `e6debc2``8cda0d7`
**Base:** `4aa7665` (main at branch point, post NEO-108 merge)
## Verdict
**Approve with nits**
## Summary
NEO-109 lands **comment-only** E9.M1 telemetry hook sites for Epic 5 Slice 3 encounter funnel events: **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, plus retained **`encounter_complete_denied`**. Hooks follow the **NEO-64** / **NEO-84** pattern (`TODO(E9.M1)`, planned payload fields, no ingest or `ILogger`). Duplicate **`encounter_complete`** stub in **`EncounterCombatWiring`** is removed in favor of a delegate pointer to **`EncounterCompletionOperations.TryCompleteAndGrant`**; NEO-84 reserved **`encounter_start`** in **`CombatOperations`** now points at **`EncounterProgressOperations`**. Docs (implementation plan, backlog E5M3-10, E5.M3 module snapshot, alignment register, module register, `server/README.md`) are updated consistently. No tests changed — appropriate for comments-only work. Godot client correctly remains **NEO-110** / **NEO-111**. Overall risk is negligible.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-109-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation section accurate. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-10) | **Matches** — acceptance criteria checked; landed note cites README + plan. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — NEO-109 telemetry hooks snapshot bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-109**; outstanding work updated to **E5M3-11+ client HUD**. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-109** landed; **E5M3-11+** outstanding. |
| `docs/decomposition/epics/epic_05_pve_combat.md` (Slice 3) | **Matches** — Slice 3 vocabulary **`encounter_complete`** + reward attribution reflected; **`encounter_start`** is Slice 1 reserved hook now anchored per NEO-84 → NEO-109 (plan documents this). |
| Full-stack epic decomposition | **N/A** — plan explicitly states no client counterpart; server-only comments. |
| `server/README.md` | **Matches** — new **Encounter telemetry hooks (NEO-109)** section; NEO-106 table and NEO-84 combat hooks cross-link correctly. |
## Blocking issues
None.
## Suggestions
1. ~~**`encounter_start` hook anchor vs emit condition** — The comment block sits **before** `progressStore.TryActivate`, but the TODO correctly says emit **when `TryActivate` returns true**. On re-engagement after activation, execution still passes the comment then `TryActivate` returns `false`. For E9.M1 wiring clarity, consider moving the block to immediately **after** a successful `TryActivate` (or wrapping with `if (progressStore.TryActivate(...)) { /* hook */ return true; }`) so the anchor matches the once-per-activation contract. Comments-only today; non-blocking.~~ **Done.** Hook block moved after successful **`TryActivate`**; method returns **`true`** on first activation (same outward behavior).
2. ~~**`encounter_complete_denied` payload scope** — The **`Deny`** hook comment lists `playerId`, `encounterId`, `reasonCode`, but **`Deny(string reasonCode)`** has no player/encounter context. Pre-existing from NEO-105; E9.M1 will need call-site context or a signature change. Worth a one-line note in the comment (“wire at caller with playerId + encounterId”) to avoid implementer confusion.~~ **Done.** Comment notes wire at each **`TryCompleteAndGrant`** **`Deny()`** call site.
## Nits
- ~~Nit: Plan lists optional **`requiredNpcInstanceIds` count** in **`encounter_start`** payload; the comment block omits it. Fine for prototype; add when E9.M1 shapes the catalog schema.~~ **Done.** Payload comment includes **`requiredNpcInstanceIdsCount`**.
- ~~Nit: **`EncounterCombatWiring`** removed `return;` after the deny log block — behavior unchanged (method ends immediately after the delegate comment). Harmless; no action needed.~~ **Done (Bugbot PR #148).** Restored early **`return;`** after failure log to match guard-clause pattern in **`TryProcessCastOutcome`**.
- Nit: **`encounter_complete_denied`** is not in the Epic 5 Slice 3 telemetry list — plan documents the **NEO-56** deny precedent; acceptable.
## Verification
```bash
# Grep hook consolidation (manual plan check)
rg 'NEO-109|encounter_complete|reward_attribution|encounter_start' \
server/NeonSprawl.Server/Game/Encounters/
# Confirm EncounterCombatWiring has delegate pointer only, no encounter_complete emit block
# Regression (encounters module)
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~Encounters"
```
**Local run:** 81/82 Encounters-filter tests passed; 1 failure in `RewardTableDefinitionRegistryTests.Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds` (host DI at `Program.cs:39`) — appears **environment/pre-existing**, unrelated to NEO-109 comment changes. Re-run full `dotnet test NeonSprawl.sln` on CI before merge.

View File

@ -0,0 +1,62 @@
# Code review — NEO-110 (E5M3-11)
**Date:** 2026-05-31
**Scope:** Branch `NEO-110-client-encounter-progress-loot-feedback-hud` vs `c302220` (merge-base on `main`) — commits `3128f36``bcc9bf1`
**Base:** `c302220` (main at branch point, post NEO-109 merge)
## Verdict
**Approve with nits**
## Summary
NEO-110 adds the Godot client for **`GET /game/players/{id}/encounter-progress`**: **`encounter_progress_client.gd`** mirrors **`gig_progression_client.gd`** (injectable HTTP, `_busy` guard, v1 parse, signals), **`main.gd`** wires boot hydrate + defeat-triggered refresh, and **`EncounterProgressLabel`** / **`EncounterCompleteLabel`** sit in **`HudRoot`** after **`PlayerCombatHpLabel`**. GdUnit covers parse paths, 404, and schema mismatch; manual QA and README are thorough. Architecture matches server authority and NEO-108 contract. Review follow-up fixed loot rendering (**`_inventory_item_label`**), independent encounter re-paint on item defs, register drift, and extra GdUnit tests.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-110-implementation-plan.md` | **Matches** — kickoff decisions adopted; reconciliation and acceptance checklist accurate. |
| `docs/plans/E5M3-prototype-backlog.md` (E5M3-11) | **Matches** — AC checked; landed note cites plan, manual QA, client README. |
| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — NEO-110 client HUD snapshot bullet added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-110** client HUD; **E5M3-12** capstone still outstanding. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-110** landed; **NEO-111** capstone outstanding. Done. |
| `docs/manual-qa/NEO-110.md` | **Matches** — Godot-only steps, defeat chain, HUD expectations, inventory auto-refresh. |
| `client/README.md` | **Matches** — encounter HUD subsection; refresh triggers documented. |
| Full-stack epic decomposition | **Matches** — client issue **NEO-110** fulfills server **NEO-108** counterpart; not claiming E5M3-12 capstone. |
## Blocking issues
1. ~~**`_item_display_name` missing on `main.gd`**~~`_render_encounter_complete_label()` now calls **`_inventory_item_label(item_id)`**. Done.
## Suggestions
1. ~~**`_on_item_definitions_ready` coupling to inventory error**~~ — Inventory and encounter HUD re-paint independently when their respective snapshots are valid. Done.
2. ~~**Update `module_dependency_register.md` E5.M3 note**~~**NEO-110** landed + client README / manual QA pointers; **NEO-111** capstone outstanding. Done.
3. ~~**GdUnit: invalid JSON / schema mismatch**~~ — Added **`test_parse_encounter_progress_json_returns_null_for_schema_mismatch`** and **`test_invalid_schema_emits_encounter_sync_failed`**. Done.
## Nits
- Nit: Parse tests instantiate a throwaway **`EncounterProgressClient`** to call **`encounter_row`**; could use a static helper or test **`encounter_row`** via the client under test only — readability only.
- Nit: **`_busy`** drops overlapping GETs (e.g. boot + first defeat); plan accepted this for prototype; manual QA notes ~1 s lag — fine.
- Nit: **`main.gd`** grows by ~130 lines; plan keeps HUD format inline — acceptable until NEO-111 or a future extract.
## Verification
```bash
# GdUnit (set Godot path first)
export GODOT_BIN=/path/to/godot
cd client && ./addons/gdUnit4/runtest.sh -a test/encounter_progress_client_test.gd
# Manual (primary for this story)
# docs/manual-qa/NEO-110.md — server restart, F5, defeat three NPCs,
# assert 0/3 → 1/3 → 2/3 → completed (3/3), loot lines, inventory without I
# After fix: confirm third-defeat does not error in Output when EncounterCompleteLabel renders grants
```
**Reviewer note:** GdUnit was not executed in this environment (`GODOT_BIN` unset). Run locally before merge.

View File

@ -0,0 +1,68 @@
# Code review — NEO-112 (E7M1-01)
**Date:** 2026-05-31
**Scope:** Branch `NEO-112-e7m1-prototype-questdef-catalog-schemas-ci` vs `47105cc` (merge-base on `main`) — commits `be1dae3``765075f`
**Base:** `47105cc` (main at branch point, post NEO-111 merge)
**Follow-up:** Suggestions and the prerequisites nit below are **done** (strikethrough + **Done.**).
## Verdict
**Approve with nits**
## Summary
NEO-112 delivers the Epic 7 Slice 1 **content foundation**: three JSON Schemas (`quest-def`, `quest-step-def`, `quest-objective-def`), a four-row `prototype_quests.json` catalog aligned to the E7.M1 freeze table, and ~300 lines of CI gates in `validate_content.py` (four-id allowlist, acyclic prerequisites, item/recipe/encounter cross-refs, operator-chain terminal token gate, duplicate quest/step/objective ids). Documentation is thorough—implementation plan reconciliation, CT.M1 paragraph, `content/README.md`, alignment register, and the full E7M1 prototype backlog land together. No server, C#, or Godot changes (correct for E7M1-01). Risk is low: pattern mirrors NEO-100/NEO-87; happy-path validation passes locally.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-112-implementation-plan.md` | **Matches** — acceptance checklist checked; reconciliation section accurate. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-01) | **Matches** — AC checked; scope/out-of-scope correct; client counterpart none. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — freeze table, CI enforcement line, and Status note NEO-112 catalog landed / runtime from NEO-113. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — quest catalog PR gate paragraph describes shipped validators. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-112 catalog; register stays Planned until NEO-113. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note references NEO-112 catalog landed; register **Planned** until NEO-113 server load. |
| `content/README.md` | **Matches**`quests/` table row + E7M1 prototype paragraph. |
| `docs/decomposition/epics/epic_07_quest_faction.md` | **Matches** — Slice 1 backlog link + Godot DoD clarification. |
| Full-stack epic decomposition | **N/A** — infrastructure-only story; no client counterpart required until NEO-122/NEO-123. |
## Blocking issues
(none)
## Suggestions
1. ~~**Refresh `module_dependency_register.md` E7.M1 note** — Change “Register row stays **Planned** until **NEO-112** lands” to “until **NEO-113** server load lands” so it matches `documentation_and_implementation_alignment.md` after this merge.~~ **Done.**`module_dependency_register.md` E7.M1 note updated.
2. ~~**Clarify E7.M1 module Status line** — Replace “implementation not started” with wording that E7M1-01 catalog (**NEO-112**) is landed and runtime work starts at **NEO-113**.~~ **Done.**`E7_M1_QuestStateMachine.md` Status row updated.
## Nits
- ~~Nit: E7M1 kickoff table row “Onboarding order: gather → refine → combat → chain” oversimplifies prerequisites — **`prototype_quest_combat_intro`** has no prereq (parallel path); freeze table and catalog are correct.~~ **Done.**`E7M1-prototype-backlog.md` kickoff Prerequisites row clarified.
- Nit: `quest-objective-def.schema.json` lists all kind-specific fields in `properties` plus `oneOf` branches, so schema validation may accept wrong-field-at-wrong-kind (e.g. `recipeId` on `encounter_complete`) if both are present — plan adopted this tradeoff; CI cross-ref gate still catches unknown ids.
- ~~Nit: Duplicate step/objective ids are enforced **within** a quest only, not globally across quests — acceptable for prototype; E7M1-04 HTTP projection should namespace by quest id.~~ **Superseded (Bugbot):** objective duplicate check was incorrectly scoped per-step; fixed in `validate_content.py` to quest-level (step ids remain per-quest; objective ids now per-quest too). Cross-quest uniqueness still not enforced (acceptable for prototype).
- Nit: Branch bundles the full E7M1 backlog decomposition (~390 lines) with E7M1-01 implementation — large diff but intentional and documented.
## Verification
```bash
# Happy path (primary regression signal — also runs in pr-gate.yml)
python3 -m pip install -r scripts/requirements-content.txt
python3 scripts/validate_content.py
# Manual negative cases (from NEO-112 plan)
# 1. Duplicate quest id across or within files → non-zero exit
# 2. Remove a frozen quest id from catalog → four-id gate error
# 3. Unknown itemId/recipeId/encounterId in objective → cross-ref error
# 4. Cyclic prerequisiteQuestIds → prerequisite gate error
# 5. Unknown prerequisite id → prerequisite gate error
# 6. Chain terminal step not inventory_has_item contract_handoff_token ×1 → chain gate error
# 7. Invalid kind or missing required field per kind → schema error
```
**Reviewer note:** Happy path executed successfully in review environment (`content OK` … `4 unique quest id(s)`).

View File

@ -0,0 +1,63 @@
# Code review — NEO-113 (E7M1-02)
**Date:** 2026-05-31
**Scope:** Branch `NEO-113-e7m1-server-quest-catalog-load-fail-fast` vs `df24281` (merge-base on `main`) — commits `cb4761b``1044547`
**Base:** `df24281` (main at branch point, post NEO-112 merge)
## Verdict
**Approve with nits**
## Summary
NEO-113 delivers fail-fast server startup loading for `content/quests/*_quests.json`, mirroring the NEO-101 / NEO-112 pattern: row DTOs, `QuestDefinitionCatalogLoader` with JsonSchema.Net validation, duplicate id gates, and `PrototypeE7M1QuestCatalogRules` for CI-parity E7M1 gates (four-id allowlist, acyclic prerequisites, item/recipe/encounter cross-refs against upstream catalogs, operator-chain terminal token). DI registers `QuestDefinitionCatalog` after item, recipe, and encounter catalogs; `Program.cs` eager-resolves before `Run()`. Eighteen AAA loader + host boot tests pass locally; documentation (implementation plan, alignment register, module register note, backlog AC, `server/README.md`) is thorough. No client work (correct). Risk is low — established catalog-load pattern with strong negative coverage; Bruno health smoke is a nice bonus.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-113-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-02) | **Matches** — AC checked; client counterpart none. |
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — freeze table, CI enforcement + server-load line, Status notes NEO-113 landed. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-113 server load; register stays Planned until NEO-114. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note updated for NEO-113 server load. |
| `server/README.md` | **Matches** — quest catalog section (config keys, discovery, fail-fast, load order, NEO-114 forward reference). |
| Full-stack epic decomposition | **N/A** — infrastructure-only; no client counterpart until NEO-122/NEO-123. |
## Blocking issues
(none)
## Suggestions
1. ~~**Refresh `E7_M1_QuestStateMachine.md` Status line** — After merge, note that **E7M1-02 / NEO-113** server load is **landed** and runtime/registry work continues from **NEO-114** (mirror the alignment register wording). Optionally add a one-line “server load (NEO-113)” bullet under **CI enforcement** pointing at `server/README.md#quest-catalog-contentquests-neo-113`.~~ **Done.**
2. ~~**Add `Load_ShouldThrow_WhenRowViolatesSchema` test** — The implementation plan and NEO-101 precedent (`EncounterDefinitionCatalogLoaderTests.Load_ShouldThrow_WhenRowViolatesSchema`) call for a row-level schema failure case (e.g. empty `displayName` or missing required objective field). Other negative paths are well covered; this is the main gap vs the plans test table.~~ **Done.**
3. ~~**Chain terminal quantity negative test** — Plan lists “wrong chain terminal objective (kind / item / quantity)”; kind and item are covered; adding a quantity ≠ 1 case would close the loop on `TryGetChainTerminalGateError`.~~ **Done.**
## Nits
- ~~Nit: Bruno `bruno/neon-sprawl-server/quest-catalog/` is not listed in the implementation plan file table — harmless smoke; consider a one-line mention in plan reconciliation if you want docs to mirror NEO-103/NEO-78 Bruno folders.~~ **Done.** — plan reconciliation lists Bruno boot smoke.
- Nit: E7M1 four-id gate error strings differ slightly from Python (`'id'` quoted list vs `repr` list) — acceptable; both are actionable.
- Nit: `QuestDefinitionCatalog` XML doc correctly defers callers to `IQuestDefinitionRegistry` (NEO-114) — good guardrail for the strict NEO-113/114 split.
## Verification
```bash
# Quest catalog tests (20 — primary signal for this story)
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~QuestDefinitionCatalogLoaderTests"
# Full server suite (CI; requires Docker for Postgres integration tests)
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
# CI parity — Python content gate still passes with repo catalog
python3 -m pip install -r scripts/requirements-content.txt
python3 scripts/validate_content.py
```
**Reviewer note:** All 20 `QuestDefinitionCatalogLoaderTests` passed locally after review follow-up. Full server suite: 602 passed; 12 Postgres/docker integration tests skipped/failed in this environment (no Docker daemon) — unrelated to NEO-113.

View File

@ -4,7 +4,10 @@ set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)" repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root" cd "$repo_root"
mapfile -t staged_files < <(git diff --cached --name-only --diff-filter=ACMR) staged_files=()
while IFS= read -r f; do
staged_files+=("$f")
done < <(git diff --cached --name-only --diff-filter=ACMR)
if [[ ${#staged_files[@]} -eq 0 ]]; then if [[ ${#staged_files[@]} -eq 0 ]]; then
exit 0 exit 0
fi fi

View File

@ -11,6 +11,69 @@ if [[ -n "$(git status --porcelain)" ]]; then
exit 1 exit 1
fi fi
zero_sha="0000000000000000000000000000000000000000"
gd_changed_files=()
collect_gd_changes_in_range() {
local range="$1"
while IFS= read -r -d '' path; do
gd_changed_files+=("$path")
done < <(
git diff --name-only -z --diff-filter=ACMR "$range" -- \
'client/scripts/*.gd' 'client/test/*.gd' 2>/dev/null || true
)
}
read_any_ref=false
while read -r local_ref local_sha remote_ref remote_sha; do
read_any_ref=true
if [[ "$local_sha" == "$zero_sha" ]]; then
continue
fi
range=""
if [[ "$remote_sha" == "$zero_sha" ]]; then
base=""
if git show-ref --verify --quiet refs/remotes/origin/main; then
base="$(git merge-base origin/main "$local_sha" 2>/dev/null || true)"
fi
if [[ -z "$base" ]] && git show-ref --verify --quiet refs/heads/main; then
base="$(git merge-base main "$local_sha" 2>/dev/null || true)"
fi
if [[ -n "$base" ]]; then
range="${base}..${local_sha}"
else
range="$local_sha"
fi
else
range="${remote_sha}..${local_sha}"
fi
collect_gd_changes_in_range "$range"
done
# Fallback when stdin is empty (non-standard invocations).
if [[ "$read_any_ref" == false ]]; then
head_sha="$(git rev-parse HEAD)"
base=""
if git show-ref --verify --quiet refs/remotes/origin/main; then
base="$(git merge-base origin/main "$head_sha" 2>/dev/null || true)"
fi
if [[ -z "$base" ]] && git show-ref --verify --quiet refs/heads/main; then
base="$(git merge-base main "$head_sha" 2>/dev/null || true)"
fi
if [[ -n "$base" ]]; then
collect_gd_changes_in_range "${base}..${head_sha}"
else
collect_gd_changes_in_range "$head_sha"
fi
fi
if [[ ${#gd_changed_files[@]} -eq 0 ]]; then
echo "pre-push: no client/scripts or client/test .gd changes in push — skipping gdlint/gdformat"
exit 0
fi
gdlint_bin="" gdlint_bin=""
gdformat_bin="" gdformat_bin=""
@ -38,6 +101,9 @@ if [[ -z "$gdlint_bin" || -z "$gdformat_bin" ]]; then
exit 1 exit 1
fi fi
echo "pre-push: client .gd changes detected — running gdlint and gdformat --check"
printf ' %s\n' "${gd_changed_files[@]}"
echo "pre-push: running gdlint on client/scripts and client/test" echo "pre-push: running gdlint on client/scripts and client/test"
"$gdlint_bin" client/scripts client/test "$gdlint_bin" client/scripts client/test

View File

@ -1,30 +1,12 @@
# Installs repo pre-commit and pre-push hooks. Same behavior as install-git-hooks.sh. # Configures core.hooksPath to scripts/git-hooks (pre-commit + pre-push).
# Run from repo root: pwsh -File scripts/install-git-hooks.ps1 # Run from repo root: pwsh -File scripts/install-git-hooks.ps1
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$repoRoot = (git rev-parse --show-toplevel).Trim() $repoRoot = (git rev-parse --show-toplevel).Trim()
$hookDir = Join-Path $repoRoot ".git/hooks" Set-Location $repoRoot
$preCommitHookPath = Join-Path $hookDir "pre-commit" git config core.hooksPath scripts/git-hooks
$hookPath = Join-Path $hookDir "pre-push" Write-Host "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)"
New-Item -ItemType Directory -Force -Path $hookDir | Out-Null Write-Host "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push."
$unixRoot = $repoRoot -replace "\\", "/" Write-Host ""
$preCommitLines = @( Write-Host "Install gdtoolkit (required when pushing GDScript changes):"
"#!/bin/sh", Write-Host " python -m venv .venv-gd"
"set -e", Write-Host " .venv-gd/Scripts/pip install `"gdtoolkit==4.5.0`""
"repo_root=`"$unixRoot`"",
'cd "$repo_root" || exit 1',
'exec sh "$repo_root/scripts/git-hooks/pre-commit" "$@"'
)
$preCommitContent = ($preCommitLines -join "`n") + "`n"
$pushLines = @(
"#!/bin/sh",
"set -e",
"repo_root=`"$unixRoot`"",
'cd "$repo_root" || exit 1',
'exec sh "$repo_root/scripts/git-hooks/pre-push" "$@"'
)
$content = ($pushLines -join "`n") + "`n"
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($preCommitHookPath, $preCommitContent, $utf8)
[System.IO.File]::WriteAllText($hookPath, $content, $utf8)
Write-Host "Installed pre-commit hook at $preCommitHookPath"
Write-Host "Installed pre-push hook at $hookPath"

View File

@ -2,29 +2,16 @@
set -euo pipefail set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)" repo_root="$(git rev-parse --show-toplevel)"
hook_dir="$repo_root/.git/hooks" cd "$repo_root"
pre_commit_hook_path="$hook_dir/pre-commit"
hook_path="$hook_dir/pre-push"
mkdir -p "$hook_dir" hooks_src="$repo_root/scripts/git-hooks"
chmod +x "$hooks_src/pre-commit" "$hooks_src/pre-push"
cat >"$pre_commit_hook_path" <<'EOF' git config core.hooksPath scripts/git-hooks
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)" echo "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)"
exec "$repo_root/scripts/git-hooks/pre-commit" "$@" echo "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push."
EOF echo ""
echo "Install gdtoolkit (required when pushing GDScript changes):"
cat >"$hook_path" <<'EOF' echo " python3 -m venv .venv-gd"
#!/usr/bin/env bash echo " .venv-gd/bin/pip install \"gdtoolkit==4.5.0\""
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
exec "$repo_root/scripts/git-hooks/pre-push" "$@"
EOF
chmod +x "$pre_commit_hook_path"
chmod +x "$hook_path"
echo "Installed pre-commit hook at $pre_commit_hook_path"
echo "Installed pre-push hook at $hook_path"

View File

@ -14,6 +14,7 @@ Validates:
- npc behavior catalogs: content/npc-behaviors/*_npc_behaviors.json rows vs content/schemas/npc-behavior-def.schema.json (NEO-87) - 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) - 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) - 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)
""" """
from __future__ import annotations from __future__ import annotations
@ -39,6 +40,9 @@ NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json"
REWARD_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reward-grant-row.schema.json" REWARD_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reward-grant-row.schema.json"
REWARD_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.schema.json" REWARD_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.schema.json"
ENCOUNTER_DEF_SCHEMA = REPO_ROOT / "content/schemas/encounter-def.schema.json" 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"
SKILLS_DIR = REPO_ROOT / "content/skills" SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery" MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items" ITEMS_DIR = REPO_ROOT / "content/items"
@ -48,6 +52,7 @@ ABILITIES_DIR = REPO_ROOT / "content/abilities"
NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors" NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables" REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters" ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
QUESTS_DIR = REPO_ROOT / "content/quests"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
@ -152,6 +157,19 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
"contract_handoff_token": 1, "contract_handoff_token": 1,
} }
# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes.
# Keep in sync with E7.M1 freeze table and future NEO-113 server loader.
PROTOTYPE_E7M1_QUEST_IDS = frozenset(
{
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro",
"prototype_quest_operator_chain",
}
)
PROTOTYPE_E7M1_CHAIN_QUEST_ID = "prototype_quest_operator_chain"
PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID = "contract_handoff_token"
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None: 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.""" """Return a human-readable error if Slice 1 contract fails, else None."""
@ -823,6 +841,236 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str
return None return None
def _quest_def_validator() -> Draft202012Validator:
"""Build a validator that resolves quest-def $ref to step and objective 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"))
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)),
]
)
return Draft202012Validator(def_schema, registry=registry)
def _validate_quest_catalogs(
*,
quest_files: list[Path],
quest_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate quest JSON files. Returns (error_count, seen_ids, id_to_row)."""
errors = 0
seen_ids: dict[str, str] = {}
id_to_row: dict[str, dict] = {}
for path in quest_files:
rel = str(path.relative_to(REPO_ROOT))
data = json.loads(path.read_text(encoding="utf-8"))
schema_version = data.get("schemaVersion")
if schema_version != 1:
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
errors += 1
continue
quests = data.get("quests")
if not isinstance(quests, list):
print(f"error: {rel}: expected top-level 'quests' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(quests):
if not isinstance(row, dict):
print(f"error: {rel}: quests[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(quest_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} quests[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
qid = row.get("id")
if isinstance(qid, str) and row_schema_errors == 0:
prev = seen_ids.get(qid)
if prev:
print(f"error: duplicate quest id {qid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[qid] = rel
id_to_row[qid] = row
steps = row.get("steps")
if isinstance(steps, list):
step_ids_seen: set[str] = set()
obj_ids_seen: set[str] = set()
for si, step in enumerate(steps):
if not isinstance(step, dict):
continue
sid = step.get("id")
if isinstance(sid, str):
if sid in step_ids_seen:
print(
f"error: {rel} quests[{i}] steps[{si}]: duplicate step id {sid!r}",
file=sys.stderr,
)
errors += 1
else:
step_ids_seen.add(sid)
objectives = step.get("objectives")
if isinstance(objectives, list):
for oi, obj in enumerate(objectives):
if not isinstance(obj, dict):
continue
oid = obj.get("id")
if isinstance(oid, str):
if oid in obj_ids_seen:
print(
f"error: {rel} quests[{i}] steps[{si}] objectives[{oi}]: "
f"duplicate objective id {oid!r} within quest",
file=sys.stderr,
)
errors += 1
else:
obj_ids_seen.add(oid)
return errors, seen_ids, id_to_row
def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E7M1 quest contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E7M1_QUEST_IDS:
return (
"error: prototype E7M1 expects exactly quest ids "
f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}"
)
return None
def _prototype_e7m1_quest_prerequisite_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if prerequisite ids are unknown or cyclic, else None."""
known = frozenset(id_to_row.keys())
for qid, row in id_to_row.items():
prereqs = row.get("prerequisiteQuestIds")
if not isinstance(prereqs, list):
continue
for pid in prereqs:
if isinstance(pid, str) and pid not in known:
return (
f"error: quest {qid!r}: prerequisiteQuestIds references unknown quest id {pid!r}"
)
visiting: set[str] = set()
visited: set[str] = set()
def dfs(node: str) -> str | None:
if node in visiting:
return f"error: cyclic prerequisiteQuestIds detected involving quest {node!r}"
if node in visited:
return None
visiting.add(node)
row = id_to_row.get(node, {})
prereqs = row.get("prerequisiteQuestIds", [])
if isinstance(prereqs, list):
for pid in prereqs:
if isinstance(pid, str):
err = dfs(pid)
if err:
return err
visiting.remove(node)
visited.add(node)
return None
for qid in known:
err = dfs(qid)
if err:
return err
return None
def _prototype_e7m1_quest_cross_ref_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if objective catalog cross-refs fail, else None."""
for qid, row in id_to_row.items():
steps = row.get("steps")
if not isinstance(steps, list):
continue
for si, step in enumerate(steps):
if not isinstance(step, dict):
continue
objectives = step.get("objectives")
if not isinstance(objectives, list):
continue
for oi, obj in enumerate(objectives):
if not isinstance(obj, dict):
continue
kind = obj.get("kind")
item_id = obj.get("itemId")
if isinstance(item_id, str) and kind in ("gather_item", "inventory_has_item"):
if item_id not in PROTOTYPE_SLICE1_ITEM_IDS:
return (
f"error: quest {qid!r} steps[{si}] objectives[{oi}]: itemId {item_id!r} "
"is not in the frozen prototype item catalog"
)
recipe_id = obj.get("recipeId")
if isinstance(recipe_id, str) and kind == "craft_recipe":
if recipe_id not in PROTOTYPE_SLICE3_RECIPE_IDS:
return (
f"error: quest {qid!r} steps[{si}] objectives[{oi}]: recipeId {recipe_id!r} "
"is not in the frozen prototype recipe catalog"
)
encounter_id = obj.get("encounterId")
if isinstance(encounter_id, str) and kind == "encounter_complete":
if encounter_id not in PROTOTYPE_E5M3_ENCOUNTER_IDS:
return (
f"error: quest {qid!r} steps[{si}] objectives[{oi}]: encounterId "
f"{encounter_id!r} is not in the frozen prototype encounter catalog"
)
return None
def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if operator chain terminal step fails, else None."""
chain = id_to_row.get(PROTOTYPE_E7M1_CHAIN_QUEST_ID)
if not isinstance(chain, dict):
return f"error: missing chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r}"
steps = chain.get("steps")
if not isinstance(steps, list) or len(steps) == 0:
return f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} must have at least one step"
last_step = steps[-1]
if not isinstance(last_step, dict):
return f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal step must be an object"
objectives = last_step.get("objectives")
if not isinstance(objectives, list) or len(objectives) != 1:
return (
f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal step must have "
"exactly one objective"
)
obj = objectives[0]
if not isinstance(obj, dict):
return (
f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal objective must be an object"
)
if obj.get("kind") != "inventory_has_item":
return (
f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal objective kind must be "
"'inventory_has_item'"
)
if obj.get("itemId") != PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID:
return (
f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal itemId must be "
f"{PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID!r}, got {obj.get('itemId')!r}"
)
if obj.get("quantity") != 1:
return (
f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal quantity must be 1, "
f"got {obj.get('quantity')!r}"
)
return None
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None: def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None.""" """Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1: if len(track_skill_ids) != 1:
@ -1158,6 +1406,15 @@ def main() -> int:
if not ENCOUNTER_DEF_SCHEMA.is_file(): if not ENCOUNTER_DEF_SCHEMA.is_file():
print(f"error: missing schema {ENCOUNTER_DEF_SCHEMA}", file=sys.stderr) print(f"error: missing schema {ENCOUNTER_DEF_SCHEMA}", file=sys.stderr)
return 1 return 1
if not QUEST_OBJECTIVE_DEF_SCHEMA.is_file():
print(f"error: missing schema {QUEST_OBJECTIVE_DEF_SCHEMA}", file=sys.stderr)
return 1
if not QUEST_STEP_DEF_SCHEMA.is_file():
print(f"error: missing schema {QUEST_STEP_DEF_SCHEMA}", file=sys.stderr)
return 1
if not QUEST_DEF_SCHEMA.is_file():
print(f"error: missing schema {QUEST_DEF_SCHEMA}", file=sys.stderr)
return 1
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema) skill_validator = Draft202012Validator(skill_schema)
@ -1179,6 +1436,7 @@ def main() -> int:
reward_table_validator = _reward_table_def_validator() reward_table_validator = _reward_table_def_validator()
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8")) encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
encounter_validator = Draft202012Validator(encounter_schema) encounter_validator = Draft202012Validator(encounter_schema)
quest_validator = _quest_def_validator()
if not SKILLS_DIR.is_dir(): if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -1271,6 +1529,15 @@ def main() -> int:
print(f"error: no *_encounters.json files under {ENCOUNTERS_DIR}", file=sys.stderr) print(f"error: no *_encounters.json files under {ENCOUNTERS_DIR}", file=sys.stderr)
return 1 return 1
if not QUESTS_DIR.is_dir():
print(f"error: missing directory {QUESTS_DIR}", file=sys.stderr)
return 1
quest_files = sorted(QUESTS_DIR.glob("*_quests.json"))
if not quest_files:
print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {} seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {} id_to_category: dict[str, str] = {}
errors = 0 errors = 0
@ -1536,6 +1803,34 @@ def main() -> int:
print(e5m3_encounter_cross_ref_err, file=sys.stderr) print(e5m3_encounter_cross_ref_err, file=sys.stderr)
return 1 return 1
quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs(
quest_files=quest_files,
quest_validator=quest_validator,
)
if quest_errors:
print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr)
return 1
e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids)
if e7m1_quest_err:
print(e7m1_quest_err, file=sys.stderr)
return 1
e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row)
if e7m1_prereq_err:
print(e7m1_prereq_err, file=sys.stderr)
return 1
e7m1_cross_ref_err = _prototype_e7m1_quest_cross_ref_gate(quest_id_to_row)
if e7m1_cross_ref_err:
print(e7m1_cross_ref_err, file=sys.stderr)
return 1
e7m1_chain_terminal_err = _prototype_e7m1_chain_terminal_gate(quest_id_to_row)
if e7m1_chain_terminal_err:
print(e7m1_chain_terminal_err, file=sys.stderr)
return 1
print( print(
"content OK: " "content OK: "
f"{len(skill_files)} skill catalog file(s), " f"{len(skill_files)} skill catalog file(s), "
@ -1549,6 +1844,7 @@ def main() -> int:
f"{len(npc_behavior_files)} npc behavior catalog file(s), " f"{len(npc_behavior_files)} npc behavior catalog file(s), "
f"{len(reward_table_files)} reward table catalog file(s), " f"{len(reward_table_files)} reward table catalog file(s), "
f"{len(encounter_files)} encounter catalog file(s), " f"{len(encounter_files)} encounter catalog file(s), "
f"{len(quest_files)} quest catalog file(s), "
f"{len(seen_ids)} unique skill id(s), " f"{len(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), " f"{len(item_seen_ids)} unique item id(s), "
f"{len(node_seen_ids)} unique nodeDefId(s), " f"{len(node_seen_ids)} unique nodeDefId(s), "
@ -1557,6 +1853,7 @@ def main() -> int:
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), " f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
f"{len(reward_table_seen_ids)} unique reward table id(s), " f"{len(reward_table_seen_ids)} unique reward table id(s), "
f"{len(encounter_seen_ids)} unique encounter id(s), " f"{len(encounter_seen_ids)} unique encounter id(s), "
f"{len(quest_seen_ids)} unique quest id(s), "
f"{len(track_skill_ids)} mastery track(s)" f"{len(track_skill_ids)} mastery track(s)"
) )
return 0 return 0

View File

@ -4,7 +4,9 @@ using System.Text;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
@ -34,14 +36,17 @@ public sealed class AbilityCastApiTests
post.EnsureSuccessStatusCode(); post.EnsureSuccessStatusCode();
} }
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) =>
await LockPrototypeTargetAsync(client, PrototypeNpcRegistry.PrototypeNpcMeleeId);
private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId)
{ {
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select", "/game/players/dev-local-1/target/select",
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, TargetId = targetId,
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>(); var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
@ -49,6 +54,80 @@ public sealed class AbilityCastApiTests
Assert.True(body!.SelectionApplied); Assert.True(body!.SelectionApplied);
} }
private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId)
{
Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry));
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move",
new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector
{
X = entry.X - 1,
Y = 0.9,
Z = entry.Z - 1,
},
});
response.EnsureSuccessStatusCode();
}
private static AbilityCastRequest PulseCastRequestForTarget(string targetId) =>
new()
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = targetId,
};
private static async Task DefeatLockedTargetWithPulseAsync(
HttpClient client,
InMemoryWebApplicationFactory factory,
string targetId)
{
Assert.NotNull(factory.FakeClock);
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
await BindSlot0PulseAsync(client);
await MoveDevPlayerNearNpcAsync(client, targetId);
await LockPrototypeTargetAsync(client, targetId);
var cast = PulseCastRequestForTarget(targetId);
AbilityCastResponse? defeatBody = null;
for (var i = 0; i < 12; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(body);
Assert.True(body!.Accepted, body.ReasonCode);
Assert.NotNull(body.CombatResolution);
if (body.CombatResolution!.TargetDefeated)
{
defeatBody = body;
break;
}
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
Assert.NotNull(defeatBody);
Assert.True(defeatBody!.CombatResolution!.TargetDefeated);
}
private static int CountBagItem(PlayerInventorySnapshotResponse 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 async Task BindSlot0GuardAsync(HttpClient client) private static async Task BindSlot0GuardAsync(HttpClient client)
{ {
var post = await client.PostAsJsonAsync( var post = await client.PostAsJsonAsync(
@ -777,4 +856,113 @@ public sealed class AbilityCastApiTests
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode); Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
Assert.True(body3!.Accepted); Assert.True(body3!.Accepted);
} }
[Fact]
public async Task PostAbilityCast_ShouldGrantEncounterLootOnce_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
// Assert
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
Assert.NotNull(inventoryBefore);
Assert.NotNull(inventoryAfter);
Assert.Equal(
CountBagItem(inventoryBefore!, "scrap_metal_bulk") + 10,
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
Assert.Equal(
CountBagItem(inventoryBefore!, "contract_handoff_token") + 1,
CountBagItem(inventoryAfter!, "contract_handoff_token"));
Assert.True(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
}
[Fact]
public async Task PostAbilityCast_ShouldNotGrantEncounterLoot_WhenOnlyTwoNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var inventoryBefore = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
// Assert
var inventoryAfter = await client.GetFromJsonAsync<PlayerInventorySnapshotResponse>(
"/game/players/dev-local-1/inventory");
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
Assert.NotNull(inventoryBefore);
Assert.NotNull(inventoryAfter);
Assert.Equal(
CountBagItem(inventoryBefore!, "scrap_metal_bulk"),
CountBagItem(inventoryAfter!, "scrap_metal_bulk"));
Assert.Equal(
CountBagItem(inventoryBefore!, "contract_handoff_token"),
CountBagItem(inventoryAfter!, "contract_handoff_token"));
Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
}
[Fact]
public async Task PostAbilityCast_ShouldGrantGigXpPerDefeat_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var gigBefore = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
"/game/players/dev-local-1/gig-progression");
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
var gigAfter = await client.GetFromJsonAsync<GigProgressionSnapshotResponse>(
"/game/players/dev-local-1/gig-progression");
// Assert
Assert.NotNull(gigBefore);
Assert.NotNull(gigAfter);
var breachBefore = Assert.Single(gigBefore!.Gigs!);
var breachAfter = Assert.Single(gigAfter!.Gigs!);
Assert.Equal(0, breachBefore.Xp);
Assert.Equal(GigProgressionConstants.CombatDefeatXpAmount * 3, breachAfter.Xp);
}
} }

View File

@ -3,6 +3,7 @@ using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -23,6 +24,20 @@ public sealed class CombatTargetFixtureApiTests
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>(); var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>(); var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
var playerHealthStore = factory.Services.GetRequiredService<IPlayerCombatHealthStore>(); var playerHealthStore = factory.Services.GetRequiredService<IPlayerCombatHealthStore>();
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
var eventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
_ = progressStore.TryActivate("dev-local-1", "prototype_combat_pocket");
_ = progressStore.TryAddDefeatedTarget("dev-local-1", "prototype_combat_pocket", "prototype_npc_melee");
_ = completionStore.TryMarkCompleted("dev-local-1", "prototype_combat_pocket", DateTimeOffset.UtcNow);
_ = eventStore.TryRecord(
new EncounterCompleteEvent(
"dev-local-1",
"prototype_combat_pocket",
"prototype_combat_pocket_clear",
[new EncounterGrantApplied("scrap_metal_bulk", 10)],
DateTimeOffset.UtcNow,
"dev-local-1:prototype_combat_pocket"));
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
_ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _); _ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
@ -59,6 +74,9 @@ public sealed class CombatTargetFixtureApiTests
playerHealthStore.TryGet("dev-local-1", out var playerHealth); playerHealthStore.TryGet("dev-local-1", out var playerHealth);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp);
Assert.False(playerHealth.Defeated); Assert.False(playerHealth.Defeated);
Assert.False(progressStore.TryGetProgress("dev-local-1", "prototype_combat_pocket", out _));
Assert.False(completionStore.IsCompleted("dev-local-1", "prototype_combat_pocket"));
Assert.False(eventStore.TryGet("dev-local-1", "prototype_combat_pocket", out _));
} }
[Fact] [Fact]

View File

@ -0,0 +1,368 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterCombatWiringTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryProcessCastOutcome_ShouldActivateEncounter_WhenDamagingHitWithoutDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.True(progress.Started);
Assert.Empty(progress.DefeatedNpcInstanceIds);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldMarkDefeatWithoutCompletion_WhenOnlyOneTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldGrantLootOnce_WhenThirdTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.True(deps.CompleteEventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal($"{PlayerId}:{EncounterId}", storedEvent.IdempotencyKey);
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNoOp_WhenNpcNotInAnyEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
"unknown_npc_instance",
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out _));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldSkipActivateAndMark_WhenEncounterAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
_ = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var before);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var after);
Assert.Equal(CountBagItem(before!, "scrap_metal_bulk"), CountBagItem(after!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(before!, "contract_handoff_token"), CountBagItem(after!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNotComplete_WhenOnlyTwoTargetsDefeatedAndThirdHitNonLethal()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(
EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.ProgressStore));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldRetainProgressWithoutCompletion_WhenBagFullOnThirdDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
FillBag(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
EliteNpc,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.True(
EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.ProgressStore));
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryProcessCastOutcome_ShouldNotActivate_WhenZeroDamageWithoutDefeat()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
MeleeNpc,
damageDealt: 0,
targetDefeated: false,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(deps.ProgressStore.TryGetProgress(PlayerId, EncounterId, out var progress) && progress.Started);
}
private static void DefeatNpc(EncounterCombatWiringTestDependencies deps, string npcId)
{
EncounterCombatWiring.TryProcessCastOutcome(
PlayerId,
npcId,
damageDealt: 25,
targetDefeated: true,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.CompleteEventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
}
private static void DefeatAllRequiredTargets(EncounterCombatWiringTestDependencies deps)
{
DefeatNpc(deps, MeleeNpc);
DefeatNpc(deps, RangedNpc);
DefeatNpc(deps, EliteNpc);
}
private static void FillBag(EncounterCombatWiringTestDependencies 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 int TotalBagQuantity(PlayerInventorySnapshot snapshot)
{
var total = 0;
foreach (var slot in snapshot.BagSlots)
{
if (!slot.IsEmpty)
{
total += slot.Quantity;
}
}
return total;
}
private static EncounterCombatWiringTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCombatWiringTestDependencies(
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
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 sealed record EncounterCombatWiringTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore CompleteEventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
}

View File

@ -0,0 +1,440 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterCompletionOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryCompleteAndGrant_ShouldGrantPrototypeLootOnce_WhenAllTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.True(result.Success);
Assert.Null(result.ReasonCode);
Assert.Equal(2, result.GrantsApplied.Count);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "scrap_metal_bulk" && g.Quantity == 10);
Assert.Contains(result.GrantsApplied, g => g.ItemId == "contract_handoff_token" && g.Quantity == 1);
Assert.NotNull(result.CompleteEvent);
Assert.Equal(PlayerId, result.CompleteEvent!.Value.PlayerId);
Assert.Equal(EncounterId, result.CompleteEvent.Value.EncounterId);
Assert.Equal("prototype_combat_pocket_clear", result.CompleteEvent.Value.RewardTableId);
Assert.Equal($"{PlayerId}:{EncounterId}", result.CompleteEvent.Value.IdempotencyKey);
Assert.NotNull(result.CompletedAt);
Assert.True(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal(result.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey);
Assert.Equal(2, storedEvent.GrantedItems.Count);
Assert.Equal(10, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(inventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyAlreadyCompleted_WhenCalledTwice()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var timeProvider = TimeProvider.System;
var first = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterFirst);
// Act
var second = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
timeProvider);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterSecond);
Assert.True(first.Success);
Assert.Null(first.ReasonCode);
Assert.Equal(2, first.GrantsApplied.Count);
Assert.False(second.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, second.ReasonCode);
Assert.Empty(second.GrantsApplied);
Assert.Null(second.CompleteEvent);
Assert.Equal(10, CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(1, CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.Equal(CountBagItem(afterFirst!, "scrap_metal_bulk"), CountBagItem(afterSecond!, "scrap_metal_bulk"));
Assert.Equal(CountBagItem(afterFirst!, "contract_handoff_token"), CountBagItem(afterSecond!, "contract_handoff_token"));
Assert.True(deps.EventStore.TryGet(PlayerId, EncounterId, out var storedEvent));
Assert.Equal(first.CompleteEvent!.Value.IdempotencyKey, storedEvent.IdempotencyKey);
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyNotReady_WhenOnlyTwoTargetsDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.NotReady, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyInventoryFull_WhenBagHasNoFreeSlots()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
FillBag(deps);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackGrants_WhenCompletionMarkFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var failingCompletionStore = new CompletionStoreDeniesMark(deps.CompletionStore);
deps.InventoryStore.TryGetSnapshot(PlayerId, out var beforeInventory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
failingCompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var afterInventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.AlreadyCompleted, result.ReasonCode);
Assert.Equal(TotalBagQuantity(beforeInventory!), TotalBagQuantity(afterInventory!));
Assert.Equal(0, CountBagItem(afterInventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(afterInventory!, "contract_handoff_token"));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldRollbackFirstGrant_WhenSecondApplyFails()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
DefeatAllRequiredTargets(deps);
var inventoryStore = new InventoryStoreFullAfterFirstGrant(deps.InventoryStore, deps.ItemRegistry);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
EncounterId,
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
inventoryStore,
TimeProvider.System);
// Assert
deps.InventoryStore.TryGetSnapshot(PlayerId, out var inventory);
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.InventoryFull, result.ReasonCode);
Assert.False(deps.CompletionStore.IsCompleted(PlayerId, EncounterId));
Assert.Equal(0, CountBagItem(inventory!, "scrap_metal_bulk"));
Assert.Equal(0, CountBagItem(inventory!, "contract_handoff_token"));
Assert.False(deps.EventStore.TryGet(PlayerId, EncounterId, out _));
}
[Fact]
public async Task TryCompleteAndGrant_ShouldDenyUnknownEncounter_WhenIdIsInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var deps = ResolveDependencies(factory);
// Act
var result = EncounterCompletionOperations.TryCompleteAndGrant(
PlayerId,
"unknown_encounter",
deps.EncounterRegistry,
deps.RewardTableRegistry,
deps.ProgressStore,
deps.CompletionStore,
deps.EventStore,
deps.ItemRegistry,
deps.InventoryStore,
TimeProvider.System);
// Assert
Assert.False(result.Success);
Assert.Equal(EncounterCompletionReasonCodes.UnknownEncounter, result.ReasonCode);
Assert.False(deps.EventStore.TryGet(PlayerId, "unknown_encounter", out _));
}
private static void DefeatAllRequiredTargets(EncounterCompletionTestDependencies deps)
{
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
RangedNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
_ = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
EliteNpc,
deps.EncounterRegistry,
deps.ProgressStore,
deps.CompletionStore);
}
private static void FillBag(EncounterCompletionTestDependencies 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 EncounterCompletionTestDependencies ResolveDependencies(InMemoryWebApplicationFactory factory)
{
var services = factory.Services;
return new EncounterCompletionTestDependencies(
services.GetRequiredService<IEncounterDefinitionRegistry>(),
services.GetRequiredService<IRewardTableDefinitionRegistry>(),
services.GetRequiredService<IEncounterProgressStore>(),
services.GetRequiredService<IEncounterCompletionStore>(),
services.GetRequiredService<IEncounterCompleteEventStore>(),
services.GetRequiredService<IItemDefinitionRegistry>(),
services.GetRequiredService<IPlayerInventoryStore>());
}
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 EncounterCompletionTestDependencies(
IEncounterDefinitionRegistry EncounterRegistry,
IRewardTableDefinitionRegistry RewardTableRegistry,
IEncounterProgressStore ProgressStore,
IEncounterCompletionStore CompletionStore,
IEncounterCompleteEventStore EventStore,
IItemDefinitionRegistry ItemRegistry,
IPlayerInventoryStore InventoryStore);
private sealed class CompletionStoreDeniesMark(IEncounterCompletionStore inner) : IEncounterCompletionStore
{
public bool IsCompleted(string playerId, string encounterId) =>
inner.IsCompleted(playerId, encounterId);
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) => false;
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt) =>
inner.TryGetCompletedAt(playerId, encounterId, out completedAt);
}
/// <summary>
/// After the first successful inventory mutation, fills remaining bag slots so the next apply fails
/// while pre-flight simulate (on an empty snapshot) still passes.
/// </summary>
private sealed class InventoryStoreFullAfterFirstGrant(
IPlayerInventoryStore inner,
IItemDefinitionRegistry itemRegistry) : IPlayerInventoryStore
{
private bool _firstMutationApplied;
public bool TryGetSnapshot(string playerId, out PlayerInventorySnapshot snapshot) =>
inner.TryGetSnapshot(playerId, out snapshot);
public bool TryReplaceSnapshot(string playerId, PlayerInventorySnapshot snapshot) =>
inner.TryReplaceSnapshot(playerId, snapshot);
public bool TryMutateSnapshot(
string playerId,
Func<PlayerInventorySnapshot, PlayerInventoryMutationWrite> mutator,
out PlayerInventorySnapshot result)
{
if (!inner.TryMutateSnapshot(playerId, mutator, out result))
{
return false;
}
if (!_firstMutationApplied)
{
_firstMutationApplied = true;
FillRemainingBagSlots(playerId);
inner.TryGetSnapshot(playerId, out result);
}
return true;
}
private void FillRemainingBagSlots(string playerId)
{
for (var i = 0; i < PlayerInventorySnapshot.BagSlotCount; i++)
{
var add = PlayerInventoryOperations.TryAddStack(
playerId,
"survey_drone_kit",
quantity: 1,
itemRegistry,
inner);
if (add.Kind != PlayerInventoryMutationKind.Applied)
{
break;
}
}
}
}
}

View File

@ -202,4 +202,27 @@ public class EncounterDefinitionRegistryTests
Assert.Single(encounterList); Assert.Single(encounterList);
Assert.Equal(PrototypeCombatPocket, encounterList[0].Id); Assert.Equal(PrototypeCombatPocket, encounterList[0].Id);
} }
[Fact]
public async Task Host_ShouldResolveEncounterProgressStoresFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
var completeEventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
// Act
var activated = progressStore.TryActivate("dev-local-1", PrototypeCombatPocket);
var completed = completionStore.TryMarkCompleted(
"dev-local-1",
PrototypeCombatPocket,
new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero));
// Assert
Assert.True(activated);
Assert.True(completed);
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeCombatPocket));
Assert.IsType<InMemoryEncounterCompleteEventStore>(completeEventStore);
}
} }

View File

@ -0,0 +1,55 @@
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public class EncounterDefinitionsWorldApiTests
{
/// <summary>Frozen prototype encounter id. Keep in sync with Bruno.</summary>
public const string PrototypeCombatPocket = "prototype_combat_pocket";
/// <summary>Frozen prototype grant sequence in catalog order. Keep in sync with Bruno.</summary>
public static readonly (string ItemId, int Quantity)[] FrozenPrototypeGrantsInOrder =
[
("scrap_metal_bulk", 10),
("contract_handoff_token", 1),
];
[Fact]
public async Task GetEncounterDefinitions_ShouldReturnSchemaV1_WithFrozenEncounterAndNestedRewardTable()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/encounter-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<EncounterDefinitionsListResponse>();
Assert.NotNull(body);
Assert.Equal(EncounterDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Encounters);
Assert.Single(body.Encounters);
var encounter = body.Encounters[0];
Assert.Equal(PrototypeCombatPocket, encounter.Id);
Assert.Equal("Prototype Combat Pocket", encounter.DisplayName);
Assert.NotNull(encounter.CompletionCriteria);
Assert.Equal("defeat_all_targets", encounter.CompletionCriteria.Kind);
Assert.Equal(
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
encounter.RequiredNpcInstanceIds);
Assert.NotNull(encounter.RewardTable);
Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTable.Id);
Assert.Equal("Prototype Combat Pocket Clear", encounter.RewardTable.DisplayName);
Assert.Equal(FrozenPrototypeGrantsInOrder.Length, encounter.RewardTable.FixedGrants.Count);
for (var i = 0; i < FrozenPrototypeGrantsInOrder.Length; i++)
{
Assert.Equal(FrozenPrototypeGrantsInOrder[i].ItemId, encounter.RewardTable.FixedGrants[i].ItemId);
Assert.Equal(FrozenPrototypeGrantsInOrder[i].Quantity, encounter.RewardTable.FixedGrants[i].Quantity);
}
}
}

View File

@ -0,0 +1,361 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterProgressApiTests
{
private const string PrototypeEncounterId = "prototype_combat_pocket";
[Fact]
public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/encounter-progress");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetEncounterProgress_ShouldReturnNotFound_WhenPlayerIdIsWhitespaceOnly()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/%20/encounter-progress");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetEncounterProgress_ShouldReturnInactive_WhenNoEngagement()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
Assert.NotNull(body);
Assert.Equal(EncounterProgressListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
var row = Assert.Single(body.Encounters);
Assert.Equal(PrototypeEncounterId, row.EncounterId);
Assert.Equal(EncounterProgressApi.StateInactive, row.State);
Assert.Empty(row.DefeatedTargetIds);
Assert.Null(row.CompletedAt);
Assert.Null(row.RewardGrantSummary);
}
[Fact]
public async Task GetEncounterProgress_ShouldReturnActiveWithOneDefeat_WhenFirstNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Encounters);
Assert.Equal(EncounterProgressApi.StateActive, row.State);
Assert.Equal([PrototypeNpcRegistry.PrototypeNpcMeleeId], row.DefeatedTargetIds);
Assert.Null(row.CompletedAt);
Assert.Null(row.RewardGrantSummary);
}
[Fact]
public async Task GetEncounterProgress_ShouldReturnActiveWithTwoDefeats_WhenTwoNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
Assert.NotNull(body);
var row = Assert.Single(body!.Encounters);
Assert.Equal(EncounterProgressApi.StateActive, row.State);
Assert.Equal(
[
PrototypeNpcRegistry.PrototypeNpcMeleeId,
PrototypeNpcRegistry.PrototypeNpcRangedId,
],
row.DefeatedTargetIds);
Assert.Null(row.CompletedAt);
Assert.Null(row.RewardGrantSummary);
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
Assert.False(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId));
}
[Fact]
public async Task GetEncounterProgress_ShouldReturnCompletedWithGrantSummary_WhenAllThreeNpcDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
// Act
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcMeleeId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcRangedId);
await DefeatLockedTargetWithPulseAsync(
client,
factory,
PrototypeNpcRegistry.PrototypeNpcEliteId);
var response = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
var secondResponse = await client.GetAsync("/game/players/dev-local-1/encounter-progress");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var body = await response.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<EncounterProgressListResponse>();
Assert.NotNull(body);
Assert.NotNull(secondBody);
var row = Assert.Single(body!.Encounters);
var secondRow = Assert.Single(secondBody!.Encounters);
Assert.Equal(EncounterProgressApi.StateCompleted, row.State);
Assert.Equal(EncounterProgressApi.StateCompleted, secondRow.State);
Assert.Equal(
[
PrototypeNpcRegistry.PrototypeNpcEliteId,
PrototypeNpcRegistry.PrototypeNpcMeleeId,
PrototypeNpcRegistry.PrototypeNpcRangedId,
],
row.DefeatedTargetIds);
Assert.Equal(row.DefeatedTargetIds, secondRow.DefeatedTargetIds);
Assert.NotNull(row.CompletedAt);
Assert.NotNull(row.RewardGrantSummary);
Assert.Equal(row.CompletedAt, secondRow.CompletedAt);
Assert.Equal(2, row.RewardGrantSummary!.Count);
Assert.Equal("scrap_metal_bulk", row.RewardGrantSummary[0].ItemId);
Assert.Equal(10, row.RewardGrantSummary[0].Quantity);
Assert.Equal("contract_handoff_token", row.RewardGrantSummary[1].ItemId);
Assert.Equal(1, row.RewardGrantSummary[1].Quantity);
Assert.Equal(row.RewardGrantSummary[0].ItemId, secondRow.RewardGrantSummary![0].ItemId);
Assert.Equal(row.RewardGrantSummary[1].ItemId, secondRow.RewardGrantSummary[1].ItemId);
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
var eventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
Assert.True(completionStore.IsCompleted("dev-local-1", PrototypeEncounterId));
Assert.True(eventStore.TryGet("dev-local-1", PrototypeEncounterId, out _));
}
[Fact]
public async Task BuildSnapshot_ShouldThrow_WhenCompletedButCompleteEventMissing()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progressStore = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completionStore = factory.Services.GetRequiredService<IEncounterCompletionStore>();
var eventStore = factory.Services.GetRequiredService<IEncounterCompleteEventStore>();
Assert.True(
completionStore.TryMarkCompleted(
"dev-local-1",
PrototypeEncounterId,
DateTimeOffset.UtcNow));
// Act
var ex = Record.Exception(
() => EncounterProgressApi.BuildSnapshot(
"dev-local-1",
registry,
progressStore,
completionStore,
eventStore));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("EncounterCompleteEvent is missing", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void BuildSnapshot_ShouldThrow_WhenCompletedButCompletedAtMissing()
{
// Arrange
var registry = CreatePrototypeEncounterRegistry();
var progressStore = new InMemoryEncounterProgressStore();
var completionStore = new CompletedWithoutTimestampStore();
var eventStore = new InMemoryEncounterCompleteEventStore();
// Act
var ex = Record.Exception(
() => EncounterProgressApi.BuildSnapshot(
"dev-local-1",
registry,
progressStore,
completionStore,
eventStore));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completedAt is missing", ioe.Message, StringComparison.Ordinal);
}
private static EncounterDefinitionRegistry CreatePrototypeEncounterRegistry()
{
var row = new EncounterDefRow(
PrototypeEncounterId,
"Prototype Combat Pocket",
"defeat_all_targets",
["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"],
"prototype_combat_pocket_clear");
var catalog = new EncounterDefinitionCatalog(
"/tmp/encounters",
new Dictionary<string, EncounterDefRow>(StringComparer.Ordinal) { [PrototypeEncounterId] = row },
catalogJsonFileCount: 1);
return new EncounterDefinitionRegistry(catalog);
}
private sealed class CompletedWithoutTimestampStore : IEncounterCompletionStore
{
public bool IsCompleted(string playerId, string encounterId) =>
string.Equals(playerId, "dev-local-1", StringComparison.Ordinal)
&& string.Equals(encounterId, PrototypeEncounterId, StringComparison.Ordinal);
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt) =>
throw new NotSupportedException();
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt)
{
completedAt = default;
return false;
}
}
private static async Task BindSlot0PulseAsync(HttpClient client)
{
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
});
post.EnsureSuccessStatusCode();
}
private static async Task LockPrototypeTargetAsync(HttpClient client, string targetId)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = targetId,
});
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body);
Assert.True(body!.SelectionApplied);
}
private static async Task MoveDevPlayerNearNpcAsync(HttpClient client, string npcInstanceId)
{
Assert.True(PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry));
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move",
new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector
{
X = entry.X - 1,
Y = 0.9,
Z = entry.Z - 1,
},
});
response.EnsureSuccessStatusCode();
}
private static AbilityCastRequest PulseCastRequestForTarget(string targetId) =>
new()
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = targetId,
};
private static async Task DefeatLockedTargetWithPulseAsync(
HttpClient client,
InMemoryWebApplicationFactory factory,
string targetId)
{
Assert.NotNull(factory.FakeClock);
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
await BindSlot0PulseAsync(client);
await MoveDevPlayerNearNpcAsync(client, targetId);
await LockPrototypeTargetAsync(client, targetId);
var cast = PulseCastRequestForTarget(targetId);
AbilityCastResponse? defeatBody = null;
for (var i = 0; i < 12; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<AbilityCastResponse>();
Assert.NotNull(body);
Assert.True(body!.Accepted, body.ReasonCode);
Assert.NotNull(body.CombatResolution);
if (body.CombatResolution!.TargetDefeated)
{
defeatBody = body;
break;
}
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
Assert.NotNull(defeatBody);
Assert.True(defeatBody!.CombatResolution!.TargetDefeated);
}
}

View File

@ -0,0 +1,219 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class EncounterProgressOperationsTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
private const string EliteNpc = "prototype_npc_elite";
[Fact]
public async Task TryActivateOnFirstEngagement_ShouldStartEncounter_WhenNpcIsRequired()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Act
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
registry,
progress,
completion);
// Assert
Assert.True(activated);
Assert.True(progress.TryGetProgress(PlayerId, EncounterId, out var snapshot));
Assert.True(snapshot.Started);
Assert.Empty(snapshot.DefeatedNpcInstanceIds);
Assert.False(EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress));
}
[Fact]
public async Task TryMarkTargetDefeated_ShouldReturnFalse_WhenNotActivated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Act
var marked = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
registry,
progress,
completion);
// Assert
Assert.False(marked);
}
[Fact]
public async Task IsAllRequiredTargetsDefeated_ShouldBecomeTrue_AfterThirdDefeatInAnyOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, EliteNpc, registry, progress, completion);
// Act
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
var afterTwo = EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion);
var afterThree = EncounterProgressOperations.IsAllRequiredTargetsDefeated(PlayerId, EncounterId, registry, progress);
// Assert
Assert.False(afterTwo);
Assert.True(afterThree);
}
[Fact]
public async Task TryMarkTargetDefeated_ShouldBeIdempotent_WhenNpcAlreadyDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
// Act
var secondMark = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
registry,
progress,
completion);
// Assert
Assert.True(secondMark);
Assert.True(progress.TryGetProgress(PlayerId, EncounterId, out var snapshot));
Assert.Single(snapshot.DefeatedNpcInstanceIds);
}
[Fact]
public async Task TryMarkTargetDefeated_ShouldReturnFalse_WhenEncounterAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
_ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow);
// Act
var marked = EncounterProgressOperations.TryMarkTargetDefeated(
PlayerId,
MeleeNpc,
registry,
progress,
completion);
// Assert
Assert.False(marked);
}
[Fact]
public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenEncounterAlreadyCompleted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = completion.TryMarkCompleted(PlayerId, EncounterId, DateTimeOffset.UtcNow);
// Act
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
MeleeNpc,
registry,
progress,
completion);
// Assert
Assert.False(activated);
Assert.False(progress.TryGetProgress(PlayerId, EncounterId, out _));
}
[Fact]
public async Task TryActivateOnFirstEngagement_ShouldReturnFalse_WhenNpcNotInAnyEncounter()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
// Act
var activated = EncounterProgressOperations.TryActivateOnFirstEngagement(
PlayerId,
"prototype_npc_unknown",
registry,
progress,
completion);
// Assert
Assert.False(activated);
}
[Fact]
public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenEncounterIdHasMixedCase()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
_ = EncounterProgressOperations.TryActivateOnFirstEngagement(PlayerId, MeleeNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, MeleeNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, RangedNpc, registry, progress, completion);
_ = EncounterProgressOperations.TryMarkTargetDefeated(PlayerId, EliteNpc, registry, progress, completion);
// Act
var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
" Prototype_Combat_Pocket ",
registry,
progress);
// Assert
Assert.True(allDefeated);
}
[Fact]
public async Task IsAllRequiredTargetsDefeated_ShouldReturnTrue_WhenDefeatedSetIsSupersetOfRequired()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var registry = factory.Services.GetRequiredService<IEncounterDefinitionRegistry>();
var progress = factory.Services.GetRequiredService<IEncounterProgressStore>();
_ = progress.TryActivate(PlayerId, EncounterId);
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, RangedNpc);
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, EliteNpc);
_ = progress.TryAddDefeatedTarget(PlayerId, EncounterId, "extra_non_required_npc");
// Act
var allDefeated = EncounterProgressOperations.IsAllRequiredTargetsDefeated(
PlayerId,
EncounterId,
registry,
progress);
// Assert
Assert.True(allDefeated);
}
[Fact]
public async Task TryMarkCompleted_ShouldReturnFalseOnSecondAttempt_WhenUsingCompletionStoreDirectly()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var completion = factory.Services.GetRequiredService<IEncounterCompletionStore>();
var completedAt = new DateTimeOffset(2026, 5, 31, 12, 0, 0, TimeSpan.Zero);
// Act
var first = completion.TryMarkCompleted(PlayerId, EncounterId, completedAt);
var second = completion.TryMarkCompleted(PlayerId, EncounterId, completedAt);
// Assert
Assert.True(first);
Assert.False(second);
}
}

View File

@ -0,0 +1,67 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class InMemoryEncounterCompleteEventStoreTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private static readonly DateTimeOffset CompletedAt = new(2026, 5, 31, 20, 0, 0, TimeSpan.Zero);
[Fact]
public void TryRecord_ShouldBeIdempotent_AtStoreLayer()
{
// Arrange
var store = new InMemoryEncounterCompleteEventStore();
var completeEvent = CreatePrototypeEvent();
// Act
var first = store.TryRecord(completeEvent);
var second = store.TryRecord(completeEvent with { CompletedAt = CompletedAt.AddHours(1) });
// Assert
Assert.True(first);
Assert.False(second);
Assert.True(store.TryGet(PlayerId, EncounterId, out var stored));
Assert.Equal(completeEvent.IdempotencyKey, stored.IdempotencyKey);
Assert.Equal(CompletedAt, stored.CompletedAt);
Assert.Equal(2, stored.GrantedItems.Count);
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenPlayerIdIsEmpty()
{
// Arrange
var store = new InMemoryEncounterCompleteEventStore();
var invalidEvent = CreatePrototypeEvent() with { PlayerId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
// Assert
Assert.False(recorded);
Assert.False(store.TryGet(" ", EncounterId, out _));
}
[Fact]
public void TryRecord_ShouldFailClosed_WhenEncounterIdIsEmpty()
{
// Arrange
var store = new InMemoryEncounterCompleteEventStore();
var invalidEvent = CreatePrototypeEvent() with { EncounterId = " " };
// Act
var recorded = store.TryRecord(invalidEvent);
// Assert
Assert.False(recorded);
Assert.False(store.TryGet(PlayerId, " ", out _));
}
private static EncounterCompleteEvent CreatePrototypeEvent() =>
new(
PlayerId,
EncounterId,
"prototype_combat_pocket_clear",
[
new EncounterGrantApplied("scrap_metal_bulk", 10),
new EncounterGrantApplied("contract_handoff_token", 1),
],
CompletedAt,
$"{PlayerId}:{EncounterId}");
}

View File

@ -0,0 +1,28 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class InMemoryEncounterCompletionStoreTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private static readonly DateTimeOffset CompletedAt = new(2026, 5, 31, 18, 0, 0, TimeSpan.Zero);
[Fact]
public void TryMarkCompleted_ShouldBeIdempotent_AtStoreLayer()
{
// Arrange
var store = new InMemoryEncounterCompletionStore();
// Act
var first = store.TryMarkCompleted(PlayerId, EncounterId, CompletedAt);
var second = store.TryMarkCompleted(PlayerId, EncounterId, CompletedAt.AddHours(1));
var hasCompletedAt = store.TryGetCompletedAt(PlayerId, EncounterId, out var storedAt);
// Assert
Assert.True(first);
Assert.False(second);
Assert.True(store.IsCompleted(PlayerId, EncounterId));
Assert.True(hasCompletedAt);
Assert.Equal(CompletedAt, storedAt);
}
}

View File

@ -0,0 +1,60 @@
using NeonSprawl.Server.Game.Encounters;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Encounters;
public sealed class InMemoryEncounterProgressStoreTests
{
private const string PlayerId = "dev-local-1";
private const string EncounterId = "prototype_combat_pocket";
private const string MeleeNpc = "prototype_npc_melee";
private const string RangedNpc = "prototype_npc_ranged";
[Fact]
public void TryActivate_ShouldReturnTrueThenFalse_WhenCalledTwice()
{
// Arrange
var store = new InMemoryEncounterProgressStore();
// Act
var first = store.TryActivate(PlayerId, EncounterId);
var second = store.TryActivate(PlayerId, EncounterId);
// Assert
Assert.True(first);
Assert.False(second);
Assert.True(store.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.True(progress.Started);
Assert.Empty(progress.DefeatedNpcInstanceIds);
}
[Fact]
public void TryAddDefeatedTarget_ShouldReturnFalse_WhenEncounterNotActivated()
{
// Arrange
var store = new InMemoryEncounterProgressStore();
// Act
var added = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
// Assert
Assert.False(added);
Assert.False(store.TryGetProgress(PlayerId, EncounterId, out _));
}
[Fact]
public void TryAddDefeatedTarget_ShouldReturnFalseOnDuplicate_AndTrueForSecondNpc()
{
// Arrange
var store = new InMemoryEncounterProgressStore();
_ = store.TryActivate(PlayerId, EncounterId);
// Act
var firstAdd = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
var duplicate = store.TryAddDefeatedTarget(PlayerId, EncounterId, MeleeNpc);
var secondAdd = store.TryAddDefeatedTarget(PlayerId, EncounterId, RangedNpc);
// Assert
Assert.True(firstAdd);
Assert.False(duplicate);
Assert.True(secondAdd);
Assert.True(store.TryGetProgress(PlayerId, EncounterId, out var progress));
Assert.Equal(2, progress.DefeatedNpcInstanceIds.Count);
Assert.Contains(MeleeNpc, progress.DefeatedNpcInstanceIds);
Assert.Contains(RangedNpc, progress.DefeatedNpcInstanceIds);
}
}

View File

@ -0,0 +1,29 @@
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Tests.Game.Quests;
internal static class QuestCatalogTestPaths
{
internal static string DiscoverRepoQuestsDirectory() =>
QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoQuestDefSchemaPath() =>
QuestCatalogPathResolution.ResolveQuestDefSchemaPath(
DiscoverRepoQuestsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
internal static string DiscoverRepoQuestStepDefSchemaPath() =>
QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath(
DiscoverRepoQuestsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
internal static string DiscoverRepoQuestObjectiveDefSchemaPath() =>
QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath(
DiscoverRepoQuestsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,626 @@
using System.Collections.Frozen;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Quests;
public class QuestDefinitionCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "Intro: Salvage Run",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "gather_intro_step_salvage",
"displayName": "Gather scrap metal",
"objectives": [
{
"id": "gather_intro_obj_scrap",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 3
}
]
}
]
},
{
"id": "prototype_quest_refine_intro",
"displayName": "Intro: Refine Stock",
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
"steps": [
{
"id": "refine_intro_step_craft",
"displayName": "Refine scrap",
"objectives": [
{
"id": "refine_intro_obj_recipe",
"kind": "craft_recipe",
"recipeId": "refine_scrap_standard",
"quantity": 1
}
]
}
]
},
{
"id": "prototype_quest_combat_intro",
"displayName": "Intro: Clear the Pocket",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "combat_intro_step_encounter",
"displayName": "Clear the combat pocket",
"objectives": [
{
"id": "combat_intro_obj_encounter",
"kind": "encounter_complete",
"encounterId": "prototype_combat_pocket"
}
]
}
]
},
{
"id": "prototype_quest_operator_chain",
"displayName": "Operator Chain",
"prerequisiteQuestIds": [
"prototype_quest_gather_intro",
"prototype_quest_refine_intro",
"prototype_quest_combat_intro"
],
"steps": [
{
"id": "chain_step_gather",
"displayName": "Bulk salvage",
"objectives": [
{
"id": "chain_obj_gather",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 5
}
]
},
{
"id": "chain_step_refine",
"displayName": "Refine stock",
"objectives": [
{
"id": "chain_obj_refine",
"kind": "craft_recipe",
"recipeId": "refine_scrap_standard",
"quantity": 1
}
]
},
{
"id": "chain_step_stim",
"displayName": "Craft field stim",
"objectives": [
{
"id": "chain_obj_stim",
"kind": "craft_recipe",
"recipeId": "make_field_stim_mk0",
"quantity": 1
}
]
},
{
"id": "chain_step_token",
"displayName": "Hand off contract token",
"objectives": [
{
"id": "chain_obj_token",
"kind": "inventory_has_item",
"itemId": "contract_handoff_token",
"quantity": 1
}
]
}
]
}
]
}
""";
private static (string Root, string QuestsDir, string DefSchemaPath, string StepSchemaPath, string ObjectiveSchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-questcat-");
var questsDir = Path.Combine(root.FullName, "content", "quests");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(questsDir);
Directory.CreateDirectory(schemaDir);
var defSchemaPath = Path.Combine(schemaDir, "quest-def.schema.json");
var stepSchemaPath = Path.Combine(schemaDir, "quest-step-def.schema.json");
var objectiveSchemaPath = Path.Combine(schemaDir, "quest-objective-def.schema.json");
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true);
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true);
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
}
private static void WriteCatalog(string questsDir, string catalogJson) =>
File.WriteAllText(Path.Combine(questsDir, "prototype_quests.json"), catalogJson, Encoding.UTF8);
private static JsonObject GetQuestRow(JsonObject catalogRoot, string questId)
{
var quests = catalogRoot["quests"] as JsonArray
?? throw new InvalidOperationException("expected quests array");
foreach (var node in quests)
{
if (node is JsonObject row && row["id"]?.GetValue<string>() == questId)
return row;
}
throw new InvalidOperationException($"quest id not found: {questId}");
}
private static QuestDefinitionCatalog LoadCatalog(
string questsDir,
string defSchemaPath,
string stepSchemaPath,
string objectiveSchemaPath) =>
QuestDefinitionCatalogLoader.Load(
questsDir,
defSchemaPath,
stepSchemaPath,
objectiveSchemaPath,
KnownItemIds,
KnownRecipeIds,
KnownEncounterIds,
NullLogger.Instance);
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
{
// Arrange
var questsDir = QuestCatalogTestPaths.DiscoverRepoQuestsDirectory();
var defSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath();
var stepSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath();
var objectiveSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath();
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(4, catalog.DistinctQuestCount);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
// Act
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
// Assert
Assert.Equal(4, catalog.DistinctQuestCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
Assert.Empty(gather.PrerequisiteQuestIds);
Assert.Single(gather.Steps);
Assert.Equal("gather_item", gather.Steps[0].Objectives[0].Kind);
Assert.True(catalog.TryGetQuest("prototype_quest_operator_chain", out var chain));
Assert.NotNull(chain);
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);
}
[Fact]
public void Load_ShouldThrow_WhenQuestsIsNotArray()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(questsDir, "bad_quests.json"),
"""{"schemaVersion": 1, "quests": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'quests' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(questsDir, "bad_quests.json"),
"""{"schemaVersion": 2, "quests": []}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
const string bad = """
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "gather_intro_step_salvage",
"displayName": "Gather scrap metal",
"objectives": [
{
"id": "gather_intro_obj_scrap",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 3
}
]
}
]
}
]
}
""";
File.WriteAllText(Path.Combine(questsDir, "bad_quests.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Quest catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateQuestIdAcrossFiles()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(questsDir, "extra_quests.json"),
"""
{
"schemaVersion": 1,
"quests": [
{
"id": "prototype_quest_gather_intro",
"displayName": "Duplicate",
"prerequisiteQuestIds": [],
"steps": [
{
"id": "dup_step",
"displayName": "Dup",
"objectives": [
{
"id": "dup_obj",
"kind": "gather_item",
"itemId": "scrap_metal_bulk",
"quantity": 1
}
]
}
]
}
]
}
""",
Encoding.UTF8);
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate quest id 'prototype_quest_gather_intro'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateStepIdWithinQuest()
{
// 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 steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
steps.Add(steps[0]!.DeepClone());
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate step id 'gather_intro_step_salvage'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateObjectiveIdWithinQuest()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var firstStep = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = firstStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
objectives[0]!["id"] = "chain_obj_refine";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate objective id 'chain_obj_refine'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingFrozenQuestId()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var quests = root["quests"] as JsonArray ?? throw new InvalidOperationException("expected quests");
quests.RemoveAt(3);
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownPrerequisiteQuestId()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_missing");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prerequisiteQuestIds references unknown quest id", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenCyclicPrerequisiteQuestIds()
{
// 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["prerequisiteQuestIds"] = new JsonArray("prototype_quest_refine_intro");
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_gather_intro");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("cyclic prerequisiteQuestIds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownItemIdInObjective()
{
// 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 steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["itemId"] = "unknown_item_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("itemId 'unknown_item_id'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownRecipeIdInObjective()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
var steps = refine["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["recipeId"] = "unknown_recipe_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("recipeId 'unknown_recipe_id'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownEncounterIdInObjective()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object");
var combat = GetQuestRow(root, "prototype_quest_combat_intro");
var steps = combat["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["encounterId"] = "unknown_encounter_id";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("encounterId 'unknown_encounter_id'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenChainTerminalObjectiveIsWrongKind()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["kind"] = "craft_recipe";
obj["recipeId"] = "refine_scrap_standard";
obj["quantity"] = 1;
obj.Remove("itemId");
obj.Remove("encounterId");
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("terminal objective kind must be 'inventory_has_item'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenChainTerminalItemIsWrong()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["itemId"] = "scrap_metal_bulk";
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("terminal itemId must be 'contract_handoff_token'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenChainTerminalQuantityIsWrong()
{
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
obj["quantity"] = 2;
WriteCatalog(questsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile()
{
// Arrange
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
File.Delete(defSchemaPath);
// Act
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Host_ShouldResolveQuestCatalogFromDi()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
Assert.Equal(4, questCatalog.DistinctQuestCount);
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
Assert.NotNull(gather);
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
}
[Fact]
public void Host_ShouldFailStartup_WhenQuestsDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-quests-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:QuestsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Quest catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}

View File

@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
@ -58,6 +59,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory) var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -67,6 +71,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir); builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir); builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>

View File

@ -1,6 +1,8 @@
using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
@ -18,6 +20,7 @@ namespace NeonSprawl.Server.Game.AbilityInput;
/// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window. /// NEO-32: JSON deny <c>on_cooldown</c> when the slot is inside the server cooldown window.
/// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve. /// NEO-82: after E1 gates pass, invokes <see cref="CombatOperations.TryResolve"/>; per-ability catalog cooldown on successful resolve.
/// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP). /// NEO-44: on accept when <see cref="CombatResult.TargetDefeated"/> is true, best-effort gig XP grant via <see cref="CombatDefeatGigXpGrant"/> (not E2.M2 skill XP).
/// NEO-106: on every successful resolve, best-effort encounter activate/defeat/completion via <see cref="EncounterCombatWiring"/>.
/// </remarks> /// </remarks>
public static class AbilityCastApi public static class AbilityCastApi
{ {
@ -68,6 +71,13 @@ public static class AbilityCastApi
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore, INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore, IPlayerGigProgressionStore gigStore,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore encounterCompletionStore,
IEncounterCompleteEventStore encounterCompleteEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider clock) => TimeProvider clock) =>
{ {
@ -255,6 +265,21 @@ public static class AbilityCastApi
loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant))); loggerFactory.CreateLogger(nameof(CombatDefeatGigXpGrant)));
} }
EncounterCombatWiring.TryProcessCastOutcome(
id.Trim(),
lookupKey,
combatResult.DamageDealt,
combatResult.TargetDefeated,
encounterRegistry,
rewardTableRegistry,
encounterProgressStore,
encounterCompletionStore,
encounterCompleteEventStore,
itemRegistry,
inventoryStore,
clock,
loggerFactory.CreateLogger(nameof(EncounterCombatWiring)));
// NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit). // NEO-30 telemetry hook site: `ability_cast_requested` at authoritative accept (TODO(E9.M1): catalog emit).
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned). // Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
return JsonAbilityCast( return JsonAbilityCast(

View File

@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Combat;
public static class CombatOperations public static class CombatOperations
{ {
// --- Reserved telemetry hook sites (NEO-84): no emit until future modules --- // --- Reserved telemetry hook sites (NEO-84): no emit until future modules ---
// encounter_start — future E5.M3 encounter spawn/engage path (not wired in Slice 1). // encounter_start — E5.M3 anchor: EncounterProgressOperations.TryActivateOnFirstEngagement (NEO-109).
// player_death — future player HP / death resolution (player HP out of scope in Slice 1). // player_death — future player HP / death resolution (player HP out of scope in Slice 1).
/// <summary> /// <summary>
/// Resolves one ability against one prototype combat target using catalog damage and the health store. /// Resolves one ability against one prototype combat target using catalog damage and the health store.

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
@ -17,6 +18,10 @@ public static class CombatTargetFixtureApi
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IPlayerCombatHealthStore playerHealthStore, IPlayerCombatHealthStore playerHealthStore,
IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore encounterProgressStore,
IEncounterCompletionStore encounterCompletionStore,
IEncounterCompleteEventStore encounterCompleteEventStore,
IOptions<GamePositionOptions> gameOptions) => IOptions<GamePositionOptions> gameOptions) =>
{ {
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
@ -41,6 +46,13 @@ public static class CombatTargetFixtureApi
return Results.NotFound(); return Results.NotFound();
} }
EncounterDevFixtureOperations.ClearPlayerEncounterState(
devPlayerId,
encounterRegistry,
encounterProgressStore,
encounterCompletionStore,
encounterCompleteEventStore);
return Results.Json( return Results.Json(
new CombatTargetFixtureResponse new CombatTargetFixtureResponse
{ {

View File

@ -56,7 +56,7 @@ public static class CraftOperations
} }
} }
var simulated = CloneSnapshot(snapshot); var simulated = snapshot.Clone();
foreach (var input in scaledInputs) foreach (var input in scaledInputs)
{ {
if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry)) if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry))
@ -176,13 +176,6 @@ public static class CraftOperations
return scaled; return scaled;
} }
private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) =>
new()
{
BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots),
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots),
};
private static void CompensatingRestoreInputs( private static void CompensatingRestoreInputs(
string playerId, string playerId,
IReadOnlyList<CraftIoApplied> inputs, IReadOnlyList<CraftIoApplied> inputs,

View File

@ -76,6 +76,10 @@ public static class EncounterCatalogServiceCollectionExtensions
services.AddSingleton<IRewardTableDefinitionRegistry>(sp => services.AddSingleton<IRewardTableDefinitionRegistry>(sp =>
new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>())); new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>()));
services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();
services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();
services.AddSingleton<IEncounterCompleteEventStore, InMemoryEncounterCompleteEventStore>();
return services; return services;
} }
} }

View File

@ -0,0 +1,96 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// NEO-106: wires ability-cast combat outcomes to encounter activation, defeat progress, and completion grants.
/// Best-effort on the cast accept path — outcome does not change <c>AbilityCastResponse</c> JSON.
/// </summary>
public static class EncounterCombatWiring
{
/// <summary>
/// On damaging hits, activates encounter progress; on lethal hits, records defeat and may grant rewards.
/// </summary>
public static void TryProcessCastOutcome(
string playerId,
string targetNpcInstanceId,
int damageDealt,
bool targetDefeated,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider,
ILogger? logger = null)
{
if (damageDealt > 0)
{
EncounterProgressOperations.TryActivateOnFirstEngagement(
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
completionStore);
}
if (!targetDefeated)
{
return;
}
if (!EncounterProgressOperations.TryMarkTargetDefeated(
playerId,
targetNpcInstanceId,
encounterRegistry,
progressStore,
completionStore))
{
return;
}
if (!EncounterProgressOperations.TryResolveEncounterForNpc(
targetNpcInstanceId,
encounterRegistry,
out var encounterId,
out _))
{
return;
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
encounterId,
encounterRegistry,
progressStore))
{
return;
}
var completion = EncounterCompletionOperations.TryCompleteAndGrant(
playerId,
encounterId,
encounterRegistry,
rewardTableRegistry,
progressStore,
completionStore,
completeEventStore,
itemRegistry,
inventoryStore,
timeProvider);
if (!completion.Success)
{
logger?.LogDebug(
"Encounter completion grant skipped for player {PlayerId} encounter {EncounterId} ({ReasonCode})",
playerId,
encounterId,
completion.ReasonCode);
return;
}
// encounter_complete / reward_attribution hooks: EncounterCompletionOperations.TryCompleteAndGrant (NEO-109).
}
}

View File

@ -0,0 +1,16 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Internal completion payload returned on successful encounter grant (NEO-105).
/// Persisted via <see cref="IEncounterCompleteEventStore"/> on success (NEO-107).
/// E7.M2 quest-credit routing consumes this record (hook stub on NEO-107).
/// NEO-109 telemetry: <c>encounter_complete</c> / <c>reward_attribution</c> hook sites in
/// <see cref="EncounterCompletionOperations.TryCompleteAndGrant"/>.
/// </summary>
public readonly record struct EncounterCompleteEvent(
string PlayerId,
string EncounterId,
string RewardTableId,
IReadOnlyList<EncounterGrantApplied> GrantedItems,
DateTimeOffset CompletedAt,
string IdempotencyKey);

View File

@ -0,0 +1,191 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
/// Combat defeat wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
/// NEO-109 telemetry hook sites: <see cref="TryCompleteAndGrant"/> (<c>encounter_complete</c>, <c>reward_attribution</c>),
/// <see cref="Deny"/> (<c>encounter_complete_denied</c>).
/// </summary>
public static class EncounterCompletionOperations
{
private static readonly EncounterGrantApplied[] EmptyGrants = [];
/// <summary>
/// When all required targets are defeated and the encounter is not yet completed, applies
/// <paramref name="encounterId"/>'s reward-table fixed grants then marks completion.
/// </summary>
public static EncounterCompletionResult TryCompleteAndGrant(
string playerId,
string encounterId,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition))
{
return Deny(EncounterCompletionReasonCodes.UnknownEncounter);
}
if (completionStore.IsCompleted(playerId, normalizedEncounterId))
{
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
normalizedEncounterId,
encounterRegistry,
progressStore))
{
return Deny(EncounterCompletionReasonCodes.NotReady);
}
if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable))
{
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
var grants = rewardTable.FixedGrants;
if (grants.Count == 0)
{
// Prototype catalog gates prevent empty tables at startup; dedicated reason if zero-grant tables ship later.
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
{
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
var simulated = snapshot.Clone();
foreach (var grant in grants)
{
if (!PlayerInventoryOperations.TrySimulateAddStack(
ref simulated,
grant.ItemId,
grant.Quantity,
itemRegistry))
{
return Deny(EncounterCompletionReasonCodes.InventoryFull);
}
}
var applied = new List<EncounterGrantApplied>(grants.Count);
foreach (var grant in grants)
{
var addOutcome = PlayerInventoryOperations.TryAddStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(MapInventoryReason(addOutcome.ReasonCode));
}
applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity));
}
var completedAt = timeProvider.GetUtcNow();
if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt))
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
var completeEvent = new EncounterCompleteEvent(
normalizedPlayerId,
normalizedEncounterId,
rewardTable.Id,
applied,
completedAt,
MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId));
_ = completeEventStore.TryRecord(completeEvent);
// TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent
// (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent.
// Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2.
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete` ---
// TODO(E9.M1): catalog emit — once per player+encounter on successful grant + completion mark commit.
// Planned payload: playerId, encounterId, rewardTableId, completedAt, idempotencyKey, grantedItems summary
// (EncounterCompleteEvent). No ingest or ILogger here (comments-only). EncounterCombatWiring delegates here.
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `reward_attribution` ---
// TODO(E9.M1): catalog emit — once per successful completion (batch attribution, not per TryAddStack).
// Planned payload: playerId, encounterId, rewardTableId, grantedItems[] (EncounterGrantApplied batch).
// Inventory layer still emits item_created per stack via PlayerInventoryOperations (NEO-56).
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,
GrantsApplied: applied,
CompleteEvent: completeEvent,
CompletedAt: completedAt);
}
private static string MapInventoryReason(string? reasonCode) =>
reasonCode switch
{
PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull,
PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem,
PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity,
null => EncounterCompletionReasonCodes.InventoryFull,
_ => reasonCode,
};
private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) =>
$"{normalizedPlayerId}:{normalizedEncounterId}";
private static void CompensatingRemoveGrants(
string playerId,
IReadOnlyList<EncounterGrantApplied> grants,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore)
{
for (var i = grants.Count - 1; i >= 0; i--)
{
var grant = grants[i];
_ = PlayerInventoryOperations.TryRemoveStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
}
}
private static EncounterCompletionResult Deny(string reasonCode)
{
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete_denied` ---
// TODO(E9.M1): catalog emit — structured deny with reasonCode (EncounterCompletionReasonCodes).
// Planned payload: playerId, encounterId, reasonCode — wire at each TryCompleteAndGrant Deny() call site
// (this helper has reasonCode only; callers hold playerId + encounterId). No ingest or ILogger here.
return new EncounterCompletionResult(
Success: false,
ReasonCode: reasonCode,
GrantsApplied: EmptyGrants,
CompleteEvent: null,
CompletedAt: null);
}
}

View File

@ -0,0 +1,26 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Stable deny reason codes for encounter completion operations (NEO-105).</summary>
public static class EncounterCompletionReasonCodes
{
public const string AlreadyCompleted = "already_completed";
public const string NotReady = "not_ready";
public const string UnknownEncounter = "unknown_encounter";
public const string UnknownRewardTable = "unknown_reward_table";
public const string InventoryStoreMissing = "inventory_store_missing";
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InventoryFull"/>.</summary>
public const string InventoryFull = PlayerInventoryReasonCodes.InventoryFull;
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidItem"/>.</summary>
public const string InvalidItem = PlayerInventoryReasonCodes.InvalidItem;
/// <summary>Passthrough from <see cref="PlayerInventoryReasonCodes.InvalidQuantity"/>.</summary>
public const string InvalidQuantity = PlayerInventoryReasonCodes.InvalidQuantity;
}

View File

@ -0,0 +1,15 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Server-internal encounter completion resolution envelope (NEO-105).
/// Combat wiring promotes via NEO-106; HTTP DTOs via NEO-108.
/// NEO-109 telemetry hook sites live in <see cref="EncounterCompletionOperations.TryCompleteAndGrant"/>.
/// </summary>
/// <param name="GrantsApplied">Item grants on success; empty when denied.</param>
/// <param name="CompleteEvent">Completion payload on success; null when denied.</param>
public readonly record struct EncounterCompletionResult(
bool Success,
string? ReasonCode,
IReadOnlyList<EncounterGrantApplied> GrantsApplied,
EncounterCompleteEvent? CompleteEvent,
DateTimeOffset? CompletedAt);

View File

@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>JSON body for <c>GET /game/world/encounter-definitions</c> (NEO-103).</summary>
public sealed class EncounterDefinitionsListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Loaded encounters ordered by stable <c>id</c> (ordinal), matching <see cref="IEncounterDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
[JsonPropertyName("encounters")]
public required IReadOnlyList<EncounterDefinitionJson> Encounters { get; init; }
}
/// <summary>One row in the read-only encounter definition projection.</summary>
public sealed class EncounterDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("completionCriteria")]
public required EncounterCompletionCriteriaJson CompletionCriteria { get; init; }
[JsonPropertyName("requiredNpcInstanceIds")]
public required IReadOnlyList<string> RequiredNpcInstanceIds { get; init; }
[JsonPropertyName("rewardTable")]
public required EncounterRewardTableJson RewardTable { get; init; }
}
/// <summary>Completion rule summary for one encounter row.</summary>
public sealed class EncounterCompletionCriteriaJson
{
[JsonPropertyName("kind")]
public required string Kind { get; init; }
}
/// <summary>Nested reward table summary linked from an encounter row.</summary>
public sealed class EncounterRewardTableJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("fixedGrants")]
public required IReadOnlyList<EncounterRewardGrantJson> FixedGrants { get; init; }
}
/// <summary>One deterministic item grant in a nested reward table summary.</summary>
public sealed class EncounterRewardGrantJson
{
[JsonPropertyName("itemId")]
public required string ItemId { get; init; }
[JsonPropertyName("quantity")]
public required int Quantity { get; init; }
}

View File

@ -0,0 +1,68 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Maps <c>GET /game/world/encounter-definitions</c> (NEO-103).</summary>
public static class EncounterDefinitionsWorldApi
{
public static WebApplication MapEncounterDefinitionsWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/encounter-definitions",
(IEncounterDefinitionRegistry encounterRegistry, IRewardTableDefinitionRegistry rewardTableRegistry) =>
{
var defs = encounterRegistry.GetDefinitionsInIdOrder();
var encounters = new List<EncounterDefinitionJson>(defs.Count);
foreach (var d in defs)
{
if (!rewardTableRegistry.TryGetDefinition(d.RewardTableId, out var rewardTable) ||
rewardTable is null)
{
throw new InvalidOperationException(
$"Encounter definition '{d.Id}' references reward table '{d.RewardTableId}' that is not in the loaded catalog.");
}
encounters.Add(
new EncounterDefinitionJson
{
Id = d.Id,
DisplayName = d.DisplayName,
CompletionCriteria = new EncounterCompletionCriteriaJson
{
Kind = d.CompletionCriteriaKind,
},
RequiredNpcInstanceIds = d.RequiredNpcInstanceIds,
RewardTable = MapRewardTable(rewardTable),
});
}
return Results.Json(
new EncounterDefinitionsListResponse
{
SchemaVersion = EncounterDefinitionsListResponse.CurrentSchemaVersion,
Encounters = encounters,
});
});
return app;
}
private static EncounterRewardTableJson MapRewardTable(RewardTableDefRow rewardTable)
{
var grants = new List<EncounterRewardGrantJson>(rewardTable.FixedGrants.Count);
foreach (var grant in rewardTable.FixedGrants)
{
grants.Add(
new EncounterRewardGrantJson
{
ItemId = grant.ItemId,
Quantity = grant.Quantity,
});
}
return new EncounterRewardTableJson
{
Id = rewardTable.Id,
DisplayName = rewardTable.DisplayName,
FixedGrants = grants,
};
}
}

View File

@ -0,0 +1,32 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Clears per-player encounter stores for dev Bruno/QA resets (NEO-108).</summary>
internal static class EncounterDevFixtureOperations
{
/// <summary>Removes progress, completion, and event rows for <paramref name="playerId"/> across catalog encounters.</summary>
public static void ClearPlayerEncounterState(
string playerId,
IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore eventStore)
{
foreach (var def in encounterRegistry.GetDefinitionsInIdOrder())
{
if (progressStore is InMemoryEncounterProgressStore inMemoryProgress)
{
inMemoryProgress.TryClear(playerId, def.Id);
}
if (completionStore is InMemoryEncounterCompletionStore inMemoryCompletion)
{
inMemoryCompletion.TryClear(playerId, def.Id);
}
if (eventStore is InMemoryEncounterCompleteEventStore inMemoryEvent)
{
inMemoryEvent.TryClear(playerId, def.Id);
}
}
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>One item grant applied on a successful encounter completion (NEO-105).</summary>
public readonly record struct EncounterGrantApplied(string ItemId, int Quantity);

View File

@ -0,0 +1,136 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Maps <c>GET /game/players/{{id}}/encounter-progress</c> (NEO-108).</summary>
public static class EncounterProgressApi
{
public const string StateInactive = "inactive";
public const string StateActive = "active";
public const string StateCompleted = "completed";
public static WebApplication MapEncounterProgressApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/encounter-progress",
(string id, IPositionStateStore positions, IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore, IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
return Results.Json(
BuildSnapshot(
trimmedId,
encounterRegistry,
progressStore,
completionStore,
completeEventStore));
});
return app;
}
internal static EncounterProgressListResponse BuildSnapshot(
string playerId,
IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore)
{
var defs = encounterRegistry.GetDefinitionsInIdOrder();
var rows = new List<EncounterProgressRowJson>(defs.Count);
foreach (var def in defs)
{
rows.Add(
MapRow(
playerId,
def.Id,
progressStore,
completionStore,
completeEventStore));
}
return new EncounterProgressListResponse
{
PlayerId = playerId,
Encounters = rows,
SchemaVersion = EncounterProgressListResponse.CurrentSchemaVersion,
};
}
private static EncounterProgressRowJson MapRow(
string playerId,
string encounterId,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore)
{
var hasProgress = progressStore.TryGetProgress(playerId, encounterId, out var progress);
var defeatedTargetIds = hasProgress
? progress.DefeatedNpcInstanceIds.OrderBy(static id => id, StringComparer.Ordinal).ToArray()
: Array.Empty<string>();
if (completionStore.IsCompleted(playerId, encounterId))
{
if (!completionStore.TryGetCompletedAt(playerId, encounterId, out var completedAt))
{
throw new InvalidOperationException(
$"Encounter '{encounterId}' is completed for player '{playerId}' but completedAt is missing.");
}
if (!completeEventStore.TryGet(playerId, encounterId, out var completeEvent))
{
throw new InvalidOperationException(
$"Encounter '{encounterId}' is completed for player '{playerId}' but EncounterCompleteEvent is missing.");
}
return new EncounterProgressRowJson
{
EncounterId = encounterId,
State = StateCompleted,
DefeatedTargetIds = defeatedTargetIds,
CompletedAt = completedAt,
RewardGrantSummary = MapGrantSummary(completeEvent.GrantedItems),
};
}
if (hasProgress && progress.Started)
{
return new EncounterProgressRowJson
{
EncounterId = encounterId,
State = StateActive,
DefeatedTargetIds = defeatedTargetIds,
};
}
return new EncounterProgressRowJson
{
EncounterId = encounterId,
State = StateInactive,
DefeatedTargetIds = defeatedTargetIds,
};
}
/// <summary>Preserves commit-time grant order from <see cref="EncounterCompleteEvent.GrantedItems"/> (reward-table apply order).</summary>
private static List<EncounterRewardGrantJson> MapGrantSummary(IReadOnlyList<EncounterGrantApplied> grants)
{
var summary = new List<EncounterRewardGrantJson>(grants.Count);
foreach (var grant in grants)
{
summary.Add(
new EncounterRewardGrantJson
{
ItemId = grant.ItemId,
Quantity = grant.Quantity,
});
}
return summary;
}
}

View File

@ -0,0 +1,36 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Id normalization for encounter progress/completion stores (NEO-104).</summary>
public static class EncounterProgressIds
{
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizePlayerId(string? playerId)
{
var trimmed = playerId?.Trim();
if (string.IsNullOrEmpty(trimmed))
{
return string.Empty;
}
return trimmed.ToLowerInvariant();
}
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeEncounterId(string? encounterId) => NormalizePlayerId(encounterId);
/// <summary>Trim + lowercase; empty when input is null/whitespace.</summary>
public static string NormalizeNpcInstanceId(string? npcInstanceId) => NormalizePlayerId(npcInstanceId);
/// <summary>Composite store key for <paramref name="playerId"/> + <paramref name="encounterId"/>.</summary>
public static string MakeProgressKey(string? playerId, string? encounterId)
{
var p = NormalizePlayerId(playerId);
var e = NormalizeEncounterId(encounterId);
if (p.Length == 0 || e.Length == 0)
{
return string.Empty;
}
return $"{p}\0{e}";
}
}

View File

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>JSON body for <c>GET /game/players/{{id}}/encounter-progress</c> (NEO-108).</summary>
public sealed class EncounterProgressListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Per-player rows ordered by catalog encounter <c>id</c> (ordinal).</summary>
[JsonPropertyName("encounters")]
public required IReadOnlyList<EncounterProgressRowJson> Encounters { get; init; }
}
/// <summary>Authoritative per-player progress for one encounter definition.</summary>
public sealed class EncounterProgressRowJson
{
[JsonPropertyName("encounterId")]
public required string EncounterId { get; init; }
/// <summary><c>inactive</c>, <c>active</c>, or <c>completed</c>.</summary>
[JsonPropertyName("state")]
public required string State { get; init; }
[JsonPropertyName("defeatedTargetIds")]
public required IReadOnlyList<string> DefeatedTargetIds { get; init; }
[JsonPropertyName("completedAt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTimeOffset? CompletedAt { get; init; }
[JsonPropertyName("rewardGrantSummary")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<EncounterRewardGrantJson>? RewardGrantSummary { get; init; }
}

View File

@ -0,0 +1,157 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Encounter activation and defeat-all progress orchestration (NEO-104).
/// Combat cast wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
/// NEO-109 telemetry hook site: <see cref="TryActivateOnFirstEngagement"/> (<c>encounter_start</c>).
/// </summary>
public static class EncounterProgressOperations
{
/// <summary>
/// Resolves the first encounter (id order) whose <c>requiredNpcInstanceIds</c> contains <paramref name="npcInstanceId"/>.
/// When multiple encounters share an NPC, the earliest id wins.
/// </summary>
public static bool TryResolveEncounterForNpc(
string npcInstanceId,
IEncounterDefinitionRegistry encounterRegistry,
out string encounterId,
out EncounterDefRow definition)
{
encounterId = string.Empty;
definition = null!;
var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId);
if (npcKey.Length == 0)
{
return false;
}
foreach (var row in encounterRegistry.GetDefinitionsInIdOrder())
{
if (row.RequiredNpcInstanceIds.Any(id =>
string.Equals(
EncounterProgressIds.NormalizeNpcInstanceId(id),
npcKey,
StringComparison.Ordinal)))
{
encounterId = row.Id;
definition = row;
return true;
}
}
return false;
}
/// <summary>
/// Activates encounter progress on first engagement with a required NPC.
/// No-op when already started, completed, or NPC is not part of any encounter.
/// </summary>
public static bool TryActivateOnFirstEngagement(
string playerId,
string npcInstanceId,
IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore)
{
if (!TryResolveEncounterForNpc(npcInstanceId, encounterRegistry, out var encounterId, out _))
{
return false;
}
if (completionStore.IsCompleted(playerId, encounterId))
{
return false;
}
if (!progressStore.TryActivate(playerId, encounterId))
{
return false;
}
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_start` ---
// TODO(E9.M1): catalog emit — once per player+encounter on first successful TryActivate (anchor matches emit).
// Planned payload: playerId, encounterId, npcInstanceId (engagement target),
// requiredNpcInstanceIdsCount (catalog requiredNpcInstanceIds for encounterId). No ingest or ILogger here
// (comments-only). EncounterCombatWiring calls this on first damaging hit vs a required NPC (NEO-106).
return true;
}
/// <summary>
/// Records one required NPC defeat. Requires prior activation; idempotent when the NPC was already marked defeated.
/// </summary>
public static bool TryMarkTargetDefeated(
string playerId,
string npcInstanceId,
IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore)
{
if (!TryResolveEncounterForNpc(npcInstanceId, encounterRegistry, out var encounterId, out var definition))
{
return false;
}
if (completionStore.IsCompleted(playerId, encounterId))
{
return false;
}
var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId);
if (!definition.RequiredNpcInstanceIds.Any(id =>
string.Equals(EncounterProgressIds.NormalizeNpcInstanceId(id), npcKey, StringComparison.Ordinal)))
{
return false;
}
if (!progressStore.TryGetProgress(playerId, encounterId, out var progress) || !progress.Started)
{
return false;
}
if (progress.DefeatedNpcInstanceIds.Contains(npcKey))
{
return true;
}
return progressStore.TryAddDefeatedTarget(playerId, encounterId, npcInstanceId);
}
/// <summary>
/// <c>true</c> when every <c>requiredNpcInstanceIds</c> entry is in the defeated set for a started encounter.
/// </summary>
public static bool IsAllRequiredTargetsDefeated(
string playerId,
string encounterId,
IEncounterDefinitionRegistry encounterRegistry,
IEncounterProgressStore progressStore)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var definition))
{
return false;
}
if (!progressStore.TryGetProgress(playerId, normalizedEncounterId, out var progress) || !progress.Started)
{
return false;
}
var required = new HashSet<string>(StringComparer.Ordinal);
foreach (var id in definition.RequiredNpcInstanceIds)
{
var normalized = EncounterProgressIds.NormalizeNpcInstanceId(id);
if (normalized.Length > 0)
{
required.Add(normalized);
}
}
if (required.Count == 0)
{
return false;
}
return required.IsSubsetOf(progress.DefeatedNpcInstanceIds);
}
}

View File

@ -0,0 +1,17 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Read model for per-player encounter defeat progress (NEO-104).</summary>
public sealed class EncounterProgressSnapshot(
string playerId,
string encounterId,
bool started,
IReadOnlySet<string> defeatedNpcInstanceIds)
{
public string PlayerId { get; } = playerId;
public string EncounterId { get; } = encounterId;
public bool Started { get; } = started;
public IReadOnlySet<string> DefeatedNpcInstanceIds { get; } = defeatedNpcInstanceIds;
}

View File

@ -0,0 +1,10 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Idempotent per-player encounter completion event record (NEO-107).</summary>
public interface IEncounterCompleteEventStore
{
/// <summary>First record returns <c>true</c>; replays for the same player+encounter return <c>false</c>.</summary>
bool TryRecord(EncounterCompleteEvent completeEvent);
bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent);
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Idempotent per-player encounter completion flag (NEO-104; rewards applied in NEO-105).</summary>
public interface IEncounterCompletionStore
{
bool IsCompleted(string playerId, string encounterId);
/// <summary>First mark returns <c>true</c>; replays return <c>false</c>.</summary>
bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt);
bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt);
}

View File

@ -0,0 +1,22 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Per-player encounter activation and defeat-target progress (NEO-104).</summary>
public interface IEncounterProgressStore
{
/// <summary>Returns <c>false</c> when no row exists for the player+encounter pair.</summary>
bool TryGetProgress(string playerId, string encounterId, out EncounterProgressSnapshot snapshot);
/// <summary>
/// Creates a started row with an empty defeated set.
/// Returns <c>true</c> on first activation; <c>false</c> when already started.
/// Does not consult <see cref="IEncounterCompletionStore"/> — callers that must block activation after completion
/// (e.g. <see cref="EncounterProgressOperations.TryActivateOnFirstEngagement"/>) must gate before calling this method.
/// </summary>
bool TryActivate(string playerId, string encounterId);
/// <summary>
/// Adds one defeated NPC id to an existing started row.
/// Returns <c>true</c> when newly added; <c>false</c> when duplicate, not started, or invalid ids.
/// </summary>
bool TryAddDefeatedTarget(string playerId, string encounterId, string npcInstanceId);
}

View File

@ -0,0 +1,68 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Thread-safe in-memory encounter completion event records (NEO-107).</summary>
public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEventStore
{
private readonly ConcurrentDictionary<string, EncounterCompleteEvent> eventsByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryRecord(EncounterCompleteEvent completeEvent)
{
var key = EncounterProgressIds.MakeProgressKey(completeEvent.PlayerId, completeEvent.EncounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (eventsByKey.ContainsKey(key))
{
return false;
}
eventsByKey[key] = completeEvent with
{
GrantedItems = completeEvent.GrantedItems.ToArray(),
};
return true;
}
}
/// <inheritdoc />
public bool TryGet(string playerId, string encounterId, out EncounterCompleteEvent completeEvent)
{
completeEvent = default;
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return eventsByKey.TryGetValue(key, out completeEvent);
}
}
/// <summary>Dev fixture only — removes one player+encounter event row (NEO-108 Bruno reset).</summary>
public bool TryClear(string playerId, string encounterId)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var removed = eventsByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _);
return removed;
}
}
}

View File

@ -0,0 +1,80 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Thread-safe in-memory encounter completion flags (NEO-104).</summary>
public sealed class InMemoryEncounterCompletionStore : IEncounterCompletionStore
{
private readonly ConcurrentDictionary<string, DateTimeOffset> completedAtByKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool IsCompleted(string playerId, string encounterId)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return completedAtByKey.ContainsKey(key);
}
}
/// <inheritdoc />
public bool TryMarkCompleted(string playerId, string encounterId, DateTimeOffset completedAt)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (completedAtByKey.ContainsKey(key))
{
return false;
}
completedAtByKey[key] = completedAt;
return true;
}
}
/// <inheritdoc />
public bool TryGetCompletedAt(string playerId, string encounterId, out DateTimeOffset completedAt)
{
completedAt = default;
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
return completedAtByKey.TryGetValue(key, out completedAt);
}
}
/// <summary>Dev fixture only — removes one player+encounter completion row (NEO-108 Bruno reset).</summary>
public bool TryClear(string playerId, string encounterId)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var removed = completedAtByKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _);
return removed;
}
}
}

View File

@ -0,0 +1,105 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Thread-safe in-memory encounter progress; empty at startup (NEO-104).</summary>
public sealed class InMemoryEncounterProgressStore : IEncounterProgressStore
{
private sealed class ProgressRow(bool started, HashSet<string> defeatedNpcInstanceIds)
{
public bool Started { get; } = started;
public HashSet<string> DefeatedNpcInstanceIds { get; } = defeatedNpcInstanceIds;
}
private readonly ConcurrentDictionary<string, ProgressRow> byKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> keyLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryGetProgress(string playerId, string encounterId, out EncounterProgressSnapshot snapshot)
{
snapshot = default!;
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row))
{
return false;
}
snapshot = new EncounterProgressSnapshot(
EncounterProgressIds.NormalizePlayerId(playerId),
EncounterProgressIds.NormalizeEncounterId(encounterId),
row.Started,
new HashSet<string>(row.DefeatedNpcInstanceIds, StringComparer.Ordinal));
return true;
}
}
/// <inheritdoc />
public bool TryActivate(string playerId, string encounterId)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (byKey.TryGetValue(key, out var existing) && existing.Started)
{
return false;
}
byKey[key] = new ProgressRow(
started: true,
defeatedNpcInstanceIds: new HashSet<string>(StringComparer.Ordinal));
return true;
}
}
/// <inheritdoc />
public bool TryAddDefeatedTarget(string playerId, string encounterId, string npcInstanceId)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
var npcKey = EncounterProgressIds.NormalizeNpcInstanceId(npcInstanceId);
if (key.Length == 0 || npcKey.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
if (!byKey.TryGetValue(key, out var row) || !row.Started)
{
return false;
}
return row.DefeatedNpcInstanceIds.Add(npcKey);
}
}
/// <summary>Dev fixture only — removes one player+encounter progress row (NEO-108 Bruno reset).</summary>
public bool TryClear(string playerId, string encounterId)
{
var key = EncounterProgressIds.MakeProgressKey(playerId, encounterId);
if (key.Length == 0)
{
return false;
}
lock (keyLocks.GetOrAdd(key, _ => new object()))
{
var removed = byKey.TryRemove(key, out _);
keyLocks.TryRemove(key, out _);
return removed;
}
}
}

Some files were not shown because too many files have changed in this diff Show More