Compare commits
82 Commits
fb5137c39a
...
d236aee261
| Author | SHA1 | Date |
|---|---|---|
|
|
d236aee261 | |
|
|
9c2ab5ae4d | |
|
|
38310d6ed3 | |
|
|
1044547cc5 | |
|
|
b841b64581 | |
|
|
cb4761b0b4 | |
|
|
df24281417 | |
|
|
a4ed4abadb | |
|
|
50956bac11 | |
|
|
c55c373f7f | |
|
|
765075f3cf | |
|
|
81ef9b94cb | |
|
|
91a4abfe86 | |
|
|
140d857398 | |
|
|
be1dae3872 | |
|
|
47105ccd90 | |
|
|
5e4816eddd | |
|
|
2e54fe39f8 | |
|
|
e900ba992b | |
|
|
385cabe883 | |
|
|
82c966d7d2 | |
|
|
e87064f43f | |
|
|
2bde771107 | |
|
|
cfad03e61f | |
|
|
e93fb22f1a | |
|
|
4379e42106 | |
|
|
24d598e0a2 | |
|
|
c8d475670c | |
|
|
3ce645a0f0 | |
|
|
bcc9bf1c69 | |
|
|
d81d19b859 | |
|
|
3128f36df9 | |
|
|
c3022201fe | |
|
|
df248308c7 | |
|
|
0f60423863 | |
|
|
c40bcd3ffe | |
|
|
d1a67a89bd | |
|
|
8cda0d76da | |
|
|
1a7b5312c5 | |
|
|
e6debc2968 | |
|
|
4aa7665e7c | |
|
|
40d948ff2c | |
|
|
05beec9644 | |
|
|
af80b878ad | |
|
|
a5182a4c14 | |
|
|
25f3714554 | |
|
|
ebb27aed2d | |
|
|
07ebb9efb8 | |
|
|
c1fd2053d0 | |
|
|
16018586b7 | |
|
|
51e2007d60 | |
|
|
f51617f8b5 | |
|
|
29217c0fa0 | |
|
|
555c3235ff | |
|
|
a02496c6bc | |
|
|
986cf55e73 | |
|
|
856dc8dcf0 | |
|
|
9c74933e6e | |
|
|
a7b835290c | |
|
|
8306f92554 | |
|
|
f581cf412c | |
|
|
fc09808482 | |
|
|
7656fded03 | |
|
|
01bcd7334c | |
|
|
cbb6f137fd | |
|
|
1a74fbda54 | |
|
|
009fedf65e | |
|
|
0cf82b9c0a | |
|
|
b5306aa933 | |
|
|
719a14f110 | |
|
|
0289930897 | |
|
|
91f74b7d6e | |
|
|
87dff6e3f4 | |
|
|
aaccbbbcaf | |
|
|
727ce4774a | |
|
|
aa81914e9f | |
|
|
e4e75f5dc7 | |
|
|
573c42add7 | |
|
|
935e8d5f16 | |
|
|
14b623c67f | |
|
|
9b48ec5f39 | |
|
|
62e6bfdf0a |
|
|
@ -59,7 +59,7 @@ CI also enforces **`gdlint`** and **`gdformat`** (see `gdlintrc` and `.github/wo
|
|||
`gdformat --check client/scripts client/test`
|
||||
- If formatting fails, run:
|
||||
`gdformat client/scripts client/test`
|
||||
- Install the repo’s local **pre-push** hook from the repo root to enforce those checks automatically:
|
||||
- Install the repo’s 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`
|
||||
|
||||
## GdUnit test layout (AAA)
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -52,3 +52,19 @@ Connection (dev): host `localhost`, port `5432`, database `neon_sprawl`, user `n
|
|||
### Run the client
|
||||
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -13,19 +13,21 @@ docs {
|
|||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
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 playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
const slotIndex = 4;
|
||||
|
||||
await resetPrototypeCombatTargets(bru);
|
||||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/move`,
|
||||
{ schemaVersion: 1, target: { x: -2, y: 0.9, z: -2 } },
|
||||
await moveNearPrototypeNpc(
|
||||
baseUrl,
|
||||
playerId,
|
||||
"prototype_npc_elite",
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ script:pre-request {
|
|||
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
|
||||
slots: [{ slotIndex, abilityId: "prototype_pulse" }],
|
||||
},
|
||||
jsonHeaders,
|
||||
);
|
||||
|
|
@ -48,7 +50,7 @@ script:pre-request {
|
|||
`${baseUrl}/game/players/${playerId}/ability-cast`,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
slotIndex: 0,
|
||||
slotIndex,
|
||||
abilityId: "prototype_pulse",
|
||||
targetId: "prototype_npc_elite",
|
||||
},
|
||||
|
|
@ -62,10 +64,10 @@ script:pre-request {
|
|||
|
||||
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 sleep(2500);
|
||||
await sleep(bru, 2500);
|
||||
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
|
||||
|
||||
bru.setVar("neo95CastBody", JSON.stringify(castBody));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ meta {
|
|||
seq: 1
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
|
||||
await resetPrototypeCombatTargets(bru);
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/game/players/dev-local-1/combat-health
|
||||
body: none
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: encounter-definitions
|
||||
}
|
||||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: encounter-progress
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: quest-catalog
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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).
|
||||
|
||||
## 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)
|
||||
|
||||
- **`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.
|
||||
|
||||
**Git hooks (recommended):** install the repo’s 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
|
||||
./scripts/install-git-hooks.sh
|
||||
```
|
||||
|
||||
On **Windows** (PowerShell), same hook:
|
||||
On **Windows** (PowerShell):
|
||||
|
||||
```powershell
|
||||
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-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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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/prototype_economy_hud_section.gd" id="20_economy_hud"]
|
||||
[ext_resource type="Script" path="res://scripts/gig_progression_client.gd" id="21_gig_prog"]
|
||||
[ext_resource type="Script" path="res://scripts/encounter_progress_client.gd" id="24_enc_prog"]
|
||||
[ext_resource type="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"]
|
||||
|
||||
|
|
@ -1114,6 +1115,9 @@ script = ExtResource("16_skill_prog")
|
|||
[node name="GigProgressionClient" type="Node" parent="." unique_id=2500011]
|
||||
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]
|
||||
script = ExtResource("17_recipe_defs")
|
||||
|
||||
|
|
@ -1199,6 +1203,27 @@ theme_override_font_sizes/font_size = 22
|
|||
autowrap_mode = 3
|
||||
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]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://bneo110encprog01
|
||||
|
|
@ -54,6 +54,8 @@ const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
|
|||
const SALVAGE_SKILL_ID := "salvage"
|
||||
const REFINE_SKILL_ID := "refine"
|
||||
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.
|
||||
var _move_reject_msg_token: int = 0
|
||||
|
|
@ -104,6 +106,8 @@ var _last_progression_snapshot: Dictionary = {}
|
|||
var _progression_error: String = ""
|
||||
var _last_gig_snapshot: Dictionary = {}
|
||||
var _gig_error: String = ""
|
||||
var _last_encounter_progress_snapshot: Dictionary = {}
|
||||
var _encounter_progress_error: String = ""
|
||||
var _gather_pre_scrap_qty: int = 0
|
||||
var _gather_pending_interactable_id: String = ""
|
||||
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 _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
|
||||
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel
|
||||
@onready var _encounter_progress_label: Label = $UICanvas/HudRoot/EncounterProgressLabel
|
||||
@onready var _encounter_complete_label: Label = $UICanvas/HudRoot/EncounterCompleteLabel
|
||||
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
|
||||
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
|
||||
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
|
||||
|
|
@ -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 _skill_progression_client: Node = $SkillProgressionClient
|
||||
@onready var _gig_progression_client: Node = $GigProgressionClient
|
||||
@onready var _encounter_progress_client: Node = $EncounterProgressClient
|
||||
@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient
|
||||
@onready var _craft_client: Node = $CraftClient
|
||||
@onready var _target_client: Node = $TargetSelectionClient
|
||||
|
|
@ -189,6 +196,7 @@ func _ready() -> void:
|
|||
_setup_inventory_sync()
|
||||
_setup_skill_progression_sync()
|
||||
_setup_gig_progression_sync()
|
||||
_setup_encounter_progress_sync()
|
||||
_setup_gather_interact_feedback()
|
||||
_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:
|
||||
_populate_craft_recipe_panel_if_ready()
|
||||
if not _inventory_error.is_empty():
|
||||
return
|
||||
if not _last_inventory_snapshot.is_empty():
|
||||
if _inventory_error.is_empty() and not _last_inventory_snapshot.is_empty():
|
||||
_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:
|
||||
|
|
@ -705,6 +713,135 @@ func _request_gig_progression_refresh() -> void:
|
|||
_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:
|
||||
if is_instance_valid(_gather_feedback_label):
|
||||
_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()
|
||||
if bool(resolution.get("targetDefeated", false)):
|
||||
_request_gig_progression_refresh()
|
||||
_request_encounter_progress_refresh()
|
||||
return
|
||||
var rc := reason_code.strip_edges()
|
||||
if rc.is_empty():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://moi4320kc06
|
||||
|
|
@ -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) |
|
||||
| [`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) |
|
||||
| [`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) |
|
||||
|
||||
**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 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).
|
||||
|
||||
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,14 +46,18 @@ Provide a quest state machine and reward routing that ties gathering, crafting,
|
|||
|
||||
## Implementation Slices (Backlog-Ready)
|
||||
|
||||
<a id="epic-7-slice-1"></a>
|
||||
|
||||
### Slice 1 - Quest core and persistence
|
||||
|
||||
- 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:
|
||||
- 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.
|
||||
|
||||
**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
|
||||
|
||||
- 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.
|
||||
- Mitigation: Lint bundles against E6.M4 and E3.M5 policies.
|
||||
|
||||
<a id="epic-7-definition-of-done"></a>
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
## Source anchors
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
| **Module ID** | E5.M3 |
|
||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||
| **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) |
|
||||
|
||||
## 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).
|
||||
|
||||
**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
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
| **Module ID** | E7.M1 |
|
||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||
| **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
|
||||
|
||||
|
|
@ -45,6 +46,42 @@ Data-driven quest lifecycle: start, step progression, branching, failure/reset,
|
|||
|
||||
See Epic 7 **Slice 1 — Quest core and persistence**: 3–5 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
|
||||
|
||||
- Scripting weight: keep `QuestDef` data-first; templates in decomposition/tooling path per epic.
|
||||
|
|
|
|||
|
|
@ -48,3 +48,5 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r
|
|||
## 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-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
|
|
@ -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.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.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-92–NEO-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
|
||||
|
||||
|
|
@ -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 |
|
||||
|---|---|---|---|---|---|
|
||||
| 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.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 |
|
||||
|
|
|
|||
|
|
@ -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 1–8 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**.
|
||||
|
|
@ -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 9–10.
|
||||
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 1–14 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.
|
||||
|
|
@ -161,8 +161,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET returns frozen encounter in ascending `id` order with `schemaVersion` **1**.
|
||||
- [ ] Bruno happy GET passes in CI Bruno step.
|
||||
- [x] GET returns frozen encounter in ascending `id` order with `schemaVersion` **1**.
|
||||
- [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**
|
||||
|
||||
- [ ] Defeating required NPCs accumulates progress in any order.
|
||||
- [ ] Completion predicate true only when all required ids defeated.
|
||||
- [ ] Second completion attempt on same player+encounter is idempotent no-op at store layer.
|
||||
- [x] Defeating required NPCs accumulates progress in any order.
|
||||
- [x] Completion predicate true only when all required ids defeated.
|
||||
- [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**
|
||||
|
||||
- [ ] First completion grants **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** deterministically.
|
||||
- [ ] Second completion does not duplicate grants.
|
||||
- [ ] Inventory deny fails closed without marking complete.
|
||||
- [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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
@ -233,9 +239,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Defeating third required NPC triggers exactly one completion grant.
|
||||
- [ ] Partial progress (1–2 defeats) does not grant loot.
|
||||
- [ ] Gig XP still awarded per defeat via **`CombatDefeatGigXpGrant`**.
|
||||
- [x] Defeating third required NPC triggers exactly one completion grant.
|
||||
- [x] Partial progress (1–2 defeats) does not grant loot.
|
||||
- [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**
|
||||
|
||||
- [ ] GET reflects progress after each defeat without client-side inference.
|
||||
- [ ] Completed encounter shows **`completed`** state and grant summary.
|
||||
- [ ] Bruno smoke: defeat chain → GET shows **`completed`** once.
|
||||
- [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.
|
||||
|
||||
**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.
|
||||
|
||||
|
|
@ -279,9 +289,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Completion path emits/records event exactly once per player+encounter.
|
||||
- [ ] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract.
|
||||
- [ ] No runtime behavior beyond event record + comments.
|
||||
- [x] Completion path emits/records event exactly once per player+encounter.
|
||||
- [x] Hook documents E7.M2 **`QuestRewardBundle`** consumer contract.
|
||||
- [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**
|
||||
|
||||
- [ ] 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] Hook names match [epic_05 Slice 3](../decomposition/epics/epic_05_pve_combat.md#epic-5-slice-3) vocabulary.
|
||||
- [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**
|
||||
|
||||
- [ ] Player-visible progress updates after each NPC defeat (e.g. **2/3** targets).
|
||||
- [ ] Completion label shows grant summary matching server GET.
|
||||
- [ ] Manual QA checklist exercisable without Bruno.
|
||||
- [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.
|
||||
|
||||
**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**).
|
||||
|
||||
|
|
@ -347,9 +363,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] 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**.
|
||||
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#epic-5-definition-of-done) for prototype minimums.
|
||||
- [x] Human completes script with server + client; loot readable in inventory HUD.
|
||||
- [x] Epic 5 Slice 3 AC: completing defined encounter grants expected items + quest token **once**.
|
||||
- [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**).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 3–5 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 **1–12** 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-112–NEO-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)
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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 encounter’s 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** |
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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 (1–2 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 (1–2 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** |
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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 1–2 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 1–2 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` | 0–3 ids | omitted |
|
||||
| 1–2 lethal defeats | `active` | 1–2 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** |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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-100–NEO-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-106–NEO-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** |
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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** |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)`).
|
||||
|
|
@ -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 plan’s 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.
|
||||
|
|
@ -4,7 +4,10 @@ set -euo pipefail
|
|||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
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
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -11,6 +11,69 @@ if [[ -n "$(git status --porcelain)" ]]; then
|
|||
exit 1
|
||||
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=""
|
||||
gdformat_bin=""
|
||||
|
||||
|
|
@ -38,6 +101,9 @@ if [[ -z "$gdlint_bin" || -z "$gdformat_bin" ]]; then
|
|||
exit 1
|
||||
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"
|
||||
"$gdlint_bin" client/scripts client/test
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = (git rev-parse --show-toplevel).Trim()
|
||||
$hookDir = Join-Path $repoRoot ".git/hooks"
|
||||
$preCommitHookPath = Join-Path $hookDir "pre-commit"
|
||||
$hookPath = Join-Path $hookDir "pre-push"
|
||||
New-Item -ItemType Directory -Force -Path $hookDir | Out-Null
|
||||
$unixRoot = $repoRoot -replace "\\", "/"
|
||||
$preCommitLines = @(
|
||||
"#!/bin/sh",
|
||||
"set -e",
|
||||
"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"
|
||||
Set-Location $repoRoot
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
Write-Host "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)"
|
||||
Write-Host "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push."
|
||||
Write-Host ""
|
||||
Write-Host "Install gdtoolkit (required when pushing GDScript changes):"
|
||||
Write-Host " python -m venv .venv-gd"
|
||||
Write-Host " .venv-gd/Scripts/pip install `"gdtoolkit==4.5.0`""
|
||||
|
|
|
|||
|
|
@ -2,29 +2,16 @@
|
|||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
hook_dir="$repo_root/.git/hooks"
|
||||
pre_commit_hook_path="$hook_dir/pre-commit"
|
||||
hook_path="$hook_dir/pre-push"
|
||||
cd "$repo_root"
|
||||
|
||||
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'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
exec "$repo_root/scripts/git-hooks/pre-commit" "$@"
|
||||
EOF
|
||||
|
||||
cat >"$hook_path" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
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"
|
||||
echo "Configured core.hooksPath=scripts/git-hooks (pre-commit + pre-push)"
|
||||
echo "Pre-push runs gdlint/gdformat when client/scripts or client/test .gd files change in the push."
|
||||
echo ""
|
||||
echo "Install gdtoolkit (required when pushing GDScript changes):"
|
||||
echo " python3 -m venv .venv-gd"
|
||||
echo " .venv-gd/bin/pip install \"gdtoolkit==4.5.0\""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100)
|
||||
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
|
||||
- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112)
|
||||
"""
|
||||
|
||||
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_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.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"
|
||||
MASTERY_DIR = REPO_ROOT / "content/mastery"
|
||||
ITEMS_DIR = REPO_ROOT / "content/items"
|
||||
|
|
@ -48,6 +52,7 @@ ABILITIES_DIR = REPO_ROOT / "content/abilities"
|
|||
NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
|
||||
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
|
||||
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.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
|
@ -152,6 +157,19 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
|
|||
"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:
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
"""Return a human-readable error if Slice 4 contract fails, else None."""
|
||||
if len(track_skill_ids) != 1:
|
||||
|
|
@ -1158,6 +1406,15 @@ def main() -> int:
|
|||
if not ENCOUNTER_DEF_SCHEMA.is_file():
|
||||
print(f"error: missing schema {ENCOUNTER_DEF_SCHEMA}", file=sys.stderr)
|
||||
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_validator = Draft202012Validator(skill_schema)
|
||||
|
|
@ -1179,6 +1436,7 @@ def main() -> int:
|
|||
reward_table_validator = _reward_table_def_validator()
|
||||
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
|
||||
encounter_validator = Draft202012Validator(encounter_schema)
|
||||
quest_validator = _quest_def_validator()
|
||||
|
||||
if not SKILLS_DIR.is_dir():
|
||||
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)
|
||||
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] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
errors = 0
|
||||
|
|
@ -1536,6 +1803,34 @@ def main() -> int:
|
|||
print(e5m3_encounter_cross_ref_err, file=sys.stderr)
|
||||
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(
|
||||
"content OK: "
|
||||
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(reward_table_files)} reward table 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(item_seen_ids)} unique item id(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(reward_table_seen_ids)} unique reward table 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)"
|
||||
)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ using System.Text;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
|
|
@ -34,14 +36,17 @@ public sealed class AbilityCastApiTests
|
|||
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(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
TargetId = targetId,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -49,6 +54,80 @@ public sealed class AbilityCastApiTests
|
|||
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)
|
||||
{
|
||||
var post = await client.PostAsJsonAsync(
|
||||
|
|
@ -777,4 +856,113 @@ public sealed class AbilityCastApiTests
|
|||
Assert.Equal(AbilityCastApi.ReasonOnCooldown, body2.ReasonCode);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
|
@ -23,6 +24,20 @@ public sealed class CombatTargetFixtureApiTests
|
|||
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
||||
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
|
||||
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 _);
|
||||
_ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _);
|
||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
||||
|
|
@ -59,6 +74,9 @@ public sealed class CombatTargetFixtureApiTests
|
|||
playerHealthStore.TryGet("dev-local-1", out var playerHealth);
|
||||
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp);
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -202,4 +202,27 @@ public class EncounterDefinitionRegistryTests
|
|||
Assert.Single(encounterList);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gathering;
|
|||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Quests;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
|
@ -58,6 +59,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"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:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
|
|
@ -67,6 +71,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
|
||||
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
||||
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using NeonSprawl.Server.Diagnostics;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
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-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-106: on every successful resolve, best-effort encounter activate/defeat/completion via <see cref="EncounterCombatWiring"/>.
|
||||
/// </remarks>
|
||||
public static class AbilityCastApi
|
||||
{
|
||||
|
|
@ -68,6 +71,13 @@ public static class AbilityCastApi
|
|||
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||
INpcRuntimeStateStore npcRuntimeStore,
|
||||
IPlayerGigProgressionStore gigStore,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IRewardTableDefinitionRegistry rewardTableRegistry,
|
||||
IEncounterProgressStore encounterProgressStore,
|
||||
IEncounterCompletionStore encounterCompletionStore,
|
||||
IEncounterCompleteEventStore encounterCompleteEventStore,
|
||||
IItemDefinitionRegistry itemRegistry,
|
||||
IPlayerInventoryStore inventoryStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider clock) =>
|
||||
{
|
||||
|
|
@ -255,6 +265,21 @@ public static class AbilityCastApi
|
|||
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).
|
||||
// Payload notes for E9.M1: player id = route `id`, body.SlotIndex, normalized ability id, body.TargetId (lock-aligned).
|
||||
return JsonAbilityCast(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Combat;
|
|||
public static class CombatOperations
|
||||
{
|
||||
// --- 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).
|
||||
/// <summary>
|
||||
/// Resolves one ability against one prototype combat target using catalog damage and the health store.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Encounters;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
|
|
@ -17,6 +18,10 @@ public static class CombatTargetFixtureApi
|
|||
IThreatStateStore threatStore,
|
||||
INpcRuntimeStateStore runtimeStore,
|
||||
IPlayerCombatHealthStore playerHealthStore,
|
||||
IEncounterDefinitionRegistry encounterRegistry,
|
||||
IEncounterProgressStore encounterProgressStore,
|
||||
IEncounterCompletionStore encounterCompletionStore,
|
||||
IEncounterCompleteEventStore encounterCompleteEventStore,
|
||||
IOptions<GamePositionOptions> gameOptions) =>
|
||||
{
|
||||
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
|
||||
|
|
@ -41,6 +46,13 @@ public static class CombatTargetFixtureApi
|
|||
return Results.NotFound();
|
||||
}
|
||||
|
||||
EncounterDevFixtureOperations.ClearPlayerEncounterState(
|
||||
devPlayerId,
|
||||
encounterRegistry,
|
||||
encounterProgressStore,
|
||||
encounterCompletionStore,
|
||||
encounterCompleteEventStore);
|
||||
|
||||
return Results.Json(
|
||||
new CombatTargetFixtureResponse
|
||||
{
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ public static class CraftOperations
|
|||
}
|
||||
}
|
||||
|
||||
var simulated = CloneSnapshot(snapshot);
|
||||
var simulated = snapshot.Clone();
|
||||
foreach (var input in scaledInputs)
|
||||
{
|
||||
if (!PlayerInventoryOperations.TrySimulateRemoveStack(ref simulated, input.ItemId, input.Quantity, itemRegistry))
|
||||
|
|
@ -176,13 +176,6 @@ public static class CraftOperations
|
|||
return scaled;
|
||||
}
|
||||
|
||||
private static PlayerInventorySnapshot CloneSnapshot(PlayerInventorySnapshot snapshot) =>
|
||||
new()
|
||||
{
|
||||
BagSlots = PlayerInventorySnapshot.CloneSlots(snapshot.BagSlots),
|
||||
EquipmentSlots = PlayerInventorySnapshot.CloneSlots(snapshot.EquipmentSlots),
|
||||
};
|
||||
|
||||
private static void CompensatingRestoreInputs(
|
||||
string playerId,
|
||||
IReadOnlyList<CraftIoApplied> inputs,
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ public static class EncounterCatalogServiceCollectionExtensions
|
|||
services.AddSingleton<IRewardTableDefinitionRegistry>(sp =>
|
||||
new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>()));
|
||||
|
||||
services.AddSingleton<IEncounterProgressStore, InMemoryEncounterProgressStore>();
|
||||
services.AddSingleton<IEncounterCompletionStore, InMemoryEncounterCompletionStore>();
|
||||
services.AddSingleton<IEncounterCompleteEventStore, InMemoryEncounterCompleteEventStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue