Merge pull request #147 from ViPro-Technologies/NEO-108-get-encounter-progress-wire-dtos-bruno
NEO-108: GET encounter-progress + wire DTOs + Brunopull/148/head
commit
4aa7665e7c
|
|
@ -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,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,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,
|
||||
};
|
||||
|
|
@ -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) 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-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) event record landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
|
||||
| **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) 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-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) event record landed · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) per-player GET landed · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
|
||||
| **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) |
|
||||
|
||||
## Purpose
|
||||
|
|
@ -86,14 +86,16 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
|
|||
|
||||
**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/HTTP wiring deferred to NEO-106/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).
|
||||
**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. Per-player progress HTTP deferred to NEO-108. Plan: [NEO-106 implementation plan](../../plans/NEO-106-implementation-plan.md); [server README — Encounter combat wiring](../../../server/README.md#encounter-combat-wiring-neo-106).
|
||||
**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).
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
**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). **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). **HTTP progress read (NEO-108+) outstanding.**
|
||||
**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/`. **E5M3-10+ outstanding.**
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -263,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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.
|
||||
|
|
@ -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,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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,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,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; }
|
||||
}
|
||||
|
|
@ -48,4 +48,21 @@ public sealed class InMemoryEncounterCompleteEventStore : IEncounterCompleteEven
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,21 @@ public sealed class InMemoryEncounterCompletionStore : IEncounterCompletionStore
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,4 +85,21 @@ public sealed class InMemoryEncounterProgressStore : IEncounterProgressStore
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ app.MapPlayerInventoryApi();
|
|||
app.MapPlayerCraftApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapGigProgressionSnapshotApi();
|
||||
app.MapEncounterProgressApi();
|
||||
app.MapPerkStateApi();
|
||||
if (app.Environment.IsDevelopment() ||
|
||||
app.Environment.IsEnvironment("Testing") ||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ Per-player encounter state is server-owned in **`IEncounterProgressStore`** (act
|
|||
- **`TryMarkTargetDefeated(…)`** — requires prior activation; accumulates defeats in any order; idempotent per NPC.
|
||||
- **`IsAllRequiredTargetsDefeated(playerId, encounterId, …)`** — set equality vs catalog **`requiredNpcInstanceIds`**.
|
||||
|
||||
**`GET /game/players/{id}/encounter-progress`** lands in NEO-108. Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md).
|
||||
**Per-player HTTP read:** see [Per-player encounter progress (NEO-108)](#per-player-encounter-progress-neo-108). Plan: [NEO-104 implementation plan](../../docs/plans/NEO-104-implementation-plan.md).
|
||||
|
||||
**Combat wiring (NEO-106):** see [Encounter combat wiring (NEO-106)](#encounter-combat-wiring-neo-106) — **`EncounterCombatWiring`** on **`POST …/ability-cast`** accept path.
|
||||
|
||||
|
|
@ -186,6 +186,26 @@ Prototype first completion grants **`scrap_metal_bulk` ×10** and **`contract_ha
|
|||
|
||||
Plan: [NEO-107 implementation plan](../../docs/plans/NEO-107-implementation-plan.md).
|
||||
|
||||
## Per-player encounter progress (NEO-108)
|
||||
|
||||
**`GET /game/players/{id}/encounter-progress`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`encounters`**) projecting in-memory stores — no client-side defeat inference. Unknown players return **404** (same **`IPositionStateStore`** gate as gig/skill progression).
|
||||
|
||||
Each row (catalog order via **`IEncounterDefinitionRegistry.GetDefinitionsInIdOrder()`**):
|
||||
|
||||
| Field | Source |
|
||||
|-------|--------|
|
||||
| **`encounterId`** | Catalog encounter **`id`**. |
|
||||
| **`state`** | **`inactive`** (no progress), **`active`** (started, not completed), **`completed`**. |
|
||||
| **`defeatedTargetIds`** | **`IEncounterProgressStore`** defeated NPC ids (ordinal sort); `[]` when inactive. |
|
||||
| **`completedAt`** | **`IEncounterCompletionStore`** when **`completed`**; omitted otherwise. |
|
||||
| **`rewardGrantSummary`** | **`IEncounterCompleteEventStore.GrantedItems`** when **`completed`**; omitted otherwise. |
|
||||
|
||||
Prototype: defeat all three E5.M2 NPCs via cast → row **`prototype_combat_pocket`** becomes **`completed`** with **`scrap_metal_bulk` ×10** and **`contract_handoff_token` ×1** in **`rewardGrantSummary`**. Plan: [NEO-108 implementation plan](../../docs/plans/NEO-108-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-progress/`.
|
||||
|
||||
```bash
|
||||
curl -sS -i "http://localhost:5253/game/players/dev-local-1/encounter-progress"
|
||||
```
|
||||
|
||||
## Encounter combat wiring (NEO-106)
|
||||
|
||||
On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring.TryProcessCastOutcome`** (best-effort; does not change cast JSON):
|
||||
|
|
@ -196,7 +216,7 @@ On every successful **`POST …/ability-cast`** accept, **`EncounterCombatWiring
|
|||
| **`targetDefeated: true`** | **`TryMarkTargetDefeated`**; when all three E5.M2 NPC ids defeated, **`EncounterCompletionOperations.TryCompleteAndGrant`**. |
|
||||
| Inventory full on completion | Defeat progress retained; completion/grants fail closed (retry when bag has space). |
|
||||
|
||||
**Call order on lethal accept:** aggro → cooldown → NPC runtime stop → **`CombatDefeatGigXpGrant`** (NEO-44) → **`EncounterCombatWiring`**. Gig XP per defeat is unchanged (**25** to **`breach`** each). Verify loot with **`GET …/inventory`**; per-player progress HTTP lands in NEO-108. Plan: [NEO-106 implementation plan](../../docs/plans/NEO-106-implementation-plan.md).
|
||||
**Call order on lethal accept:** aggro → cooldown → NPC runtime stop → **`CombatDefeatGigXpGrant`** (NEO-44) → **`EncounterCombatWiring`**. Gig XP per defeat is unchanged (**25** to **`breach`** each). Verify loot with **`GET …/inventory`** or **`GET …/encounter-progress`** (NEO-108). Plan: [NEO-106 implementation plan](../../docs/plans/NEO-106-implementation-plan.md).
|
||||
|
||||
## NPC behavior definitions (NEO-90)
|
||||
|
||||
|
|
@ -244,7 +264,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m
|
|||
curl -sS -i "http://localhost:5253/game/world/combat-targets"
|
||||
```
|
||||
|
||||
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), and restores dev player combat HP to full (**NEO-95**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
|
||||
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), resets NPC runtime behavior rows to **`idle`** (**NEO-93**), restores dev player combat HP to full (**NEO-95**), and clears in-memory encounter progress/completion/event rows for the dev player across catalog encounters (**NEO-108** Bruno). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js` (request scripts use `./scripts/…` relative to the collection).
|
||||
|
||||
## Threat / aggro state (NEO-92)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue