Compare commits

...

15 Commits

Author SHA1 Message Date
VinPropane 49c7bef0fd
Merge pull request #133 from ViPro-Technologies/NEO-95-npc-attack-resolve-player-hp
NEO-95: NPC attack resolve + session player combat HP
2026-05-28 23:55:02 -04:00
VinPropane c96fb2d26a NEO-95: fix Bruno elite attack — multi-poll snapshot timeline
AdvanceAll runs only on npc-runtime-snapshot GET with a 5s delta cap;
sleep then combat-health alone never applied telegraph damage.
2026-05-28 23:49:59 -04:00
VinPropane fb70777453 NEO-95: note review follow-up commit SHA in review doc 2026-05-28 23:45:11 -04:00
VinPropane 5efa5a4ad8 NEO-95: address code review — alignment docs and API nits
Append NEO-95 landed to E5.M2 tracking; echo route playerId on
combat-health GET; AttackExecute comment; tighten elite test AAA.
2026-05-28 23:45:07 -04:00
VinPropane 3afe720980 NEO-95: add code review for NPC attack resolve + player HP 2026-05-28 23:43:56 -04:00
VinPropane b702d9fb6f NEO-95: add tests, Bruno smoke, and docs for player combat HP
Cover elite telegraph damage, fixture reset, and combat-health GET
verification; update E5M2 backlog and server README.
2026-05-28 23:42:20 -04:00
VinPropane cb1692ce85 NEO-95: add player combat HP store and NPC attack resolve
Wire telegraph-complete damage into AdvanceAll and expose GET
/game/players/{id}/combat-health for session player HP read model.
2026-05-28 23:42:18 -04:00
VinPropane ac4e1dff10 NEO-95: add implementation plan for NPC attack resolve + player HP 2026-05-28 23:37:46 -04:00
VinPropane 8bbf737b2d
Merge pull request #132 from ViPro-Technologies/NEO-94-npc-runtime-snapshot-get
NEO-94: TelegraphEvent snapshot GET + wire DTOs
2026-05-28 23:35:06 -04:00
VinPropane 0268daa9d3 NEO-94: Fix Bruno require path for CI sandbox context roots.
Use ./scripts/ like combat-targets; ../scripts/ is blocked by Bruno CLI path traversal policy in CI.
2026-05-28 23:29:17 -04:00
VinPropane 55964da4ac NEO-94: verify code review follow-ups and bump verdict to Approve 2026-05-28 23:24:12 -04:00
VinPropane 798c601bc5 NEO-94: Address code review — alignment doc and AAA test layout.
Append NEO-94 landed to E5.M2 tracking row; move ReadFromJsonAsync to Assert in snapshot integration tests.
2026-05-28 23:23:19 -04:00
VinPropane e3bfcf0708 NEO-94: add code review for rewritten snapshot GET branch 2026-05-28 23:22:24 -04:00
VinPropane a68d8aa782 NEO-94: Add GET /game/world/npc-runtime-snapshot with lazy AdvanceAll.
Expose versioned NPC runtime rows and nested activeTelegraph DTOs for client poll; wire route, AAA integration tests, Bruno smoke, and docs.
2026-05-28 23:17:57 -04:00
VinPropane 54429108ad NEO-94: add implementation plan for npc-runtime-snapshot GET
Kickoff clarifications: nested activeTelegraph on npcInstances rows,
windupRemainingSeconds server-computed, no manual QA doc.
2026-05-28 22:28:31 -04:00
33 changed files with 1916 additions and 81 deletions

View File

@ -0,0 +1,95 @@
meta {
name: Get combat health after elite attack
type: http
seq: 2
}
docs {
E5M2-09 AC smoke: lock prototype_npc_elite, damaging cast, poll npc-runtime-snapshot through
attackCooldownSeconds (5.0s) + telegraphWindupSeconds (2.5s), then GET combat-health.
Lazy AdvanceAll caps at 5.0s per poll — need two snapshot GETs after cast (same as FakeClock integration test).
}
script:pre-request {
const axios = require("axios");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-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));
}
await resetPrototypeCombatTargets(bru);
await axios.post(
`${baseUrl}/game/players/${playerId}/move`,
{ schemaVersion: 1, target: { x: -2, y: 0.9, z: -2 } },
jsonHeaders,
);
await axios.post(
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
{
schemaVersion: 1,
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
},
jsonHeaders,
);
await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_npc_elite" },
jsonHeaders,
);
const castResponse = await axios.post(
`${baseUrl}/game/players/${playerId}/ability-cast`,
{
schemaVersion: 1,
slotIndex: 0,
abilityId: "prototype_pulse",
targetId: "prototype_npc_elite",
},
jsonHeaders,
);
const castBody = castResponse.data;
if (!castBody?.accepted || !castBody?.combatResolution) {
throw new Error(`neo95 cast failed: ${JSON.stringify(castBody)}`);
}
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
await sleep(5000);
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
await sleep(2500);
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
bru.setVar("neo95CastBody", JSON.stringify(castBody));
}
get {
url: {{baseUrl}}/game/players/dev-local-1/combat-health
body: none
auth: none
}
tests {
test("cast body shows accepted damaging pulse on elite", function () {
const castBody = JSON.parse(bru.getVar("neo95CastBody"));
expect(castBody.accepted).to.equal(true);
expect(castBody.combatResolution.targetRemainingHp).to.equal(175);
});
test("player HP decreased by elite attackDamage (25)", function () {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal("dev-local-1");
expect(body.maxHp).to.equal(100);
expect(body.currentHp).to.equal(75);
expect(body.defeated).to.equal(false);
});
}

View File

@ -0,0 +1,24 @@
meta {
name: Get player combat health
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/players/dev-local-1/combat-health
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and full HP baseline", 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.playerId).to.equal("dev-local-1");
expect(body.maxHp).to.equal(100);
expect(body.currentHp).to.equal(100);
expect(body.defeated).to.equal(false);
});
}

View File

@ -0,0 +1,9 @@
meta {
name: combat-health
}
docs {
NEO-95: GET /game/players/{id}/combat-health — session player combat HP after NPC telegraph resolve.
Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution.
Elite attack smoke (seq 2) polls npc-runtime-snapshot twice (5s + 2.5s waits) before combat-health GET — AdvanceAll delta cap 5.0s requires multi-poll timeline like integration tests.
}

View File

@ -0,0 +1,67 @@
meta {
name: Get npc runtime snapshot
type: http
seq: 1
}
script:pre-request {
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-helper.js");
await resetPrototypeCombatTargets(bru);
}
get {
url: {{baseUrl}}/game/world/npc-runtime-snapshot
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and npcInstances 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.serverTimeUtc).to.be.a("string");
expect(body.npcInstances).to.be.an("array");
expect(body.npcInstances.length).to.equal(3);
});
test("npcInstances are ascending by npcInstanceId (ordinal)", function () {
const body = res.getBody();
const ids = body.npcInstances.map((x) => x.npcInstanceId);
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
expect(ids).to.eql(sorted);
});
test("prototype NPC registry instances match id order", function () {
const body = res.getBody();
const ids = body.npcInstances.map((x) => x.npcInstanceId);
expect(ids).to.eql([
"prototype_npc_elite",
"prototype_npc_melee",
"prototype_npc_ranged",
]);
});
test("all instances start idle with null holder and telegraph on fresh server", function () {
const body = res.getBody();
for (const row of body.npcInstances) {
expect(row.state).to.equal("idle");
expect(row.aggroHolderPlayerId).to.equal(null);
expect(row.activeTelegraph).to.equal(null);
expect(row.behaviorDefId).to.be.a("string");
}
const byId = Object.fromEntries(
body.npcInstances.map((x) => [x.npcInstanceId, x]),
);
expect(byId.prototype_npc_melee.behaviorDefId).to.equal(
"prototype_melee_pressure",
);
expect(byId.prototype_npc_ranged.behaviorDefId).to.equal(
"prototype_ranged_control",
);
expect(byId.prototype_npc_elite.behaviorDefId).to.equal(
"prototype_elite_mini_boss",
);
});
}

View File

@ -0,0 +1,109 @@
meta {
name: Get snapshot after cast telegraph
type: http
seq: 2
}
docs {
E5M2-08 AC smoke: lock prototype_npc_melee, damaging cast, wait attackCooldownSeconds (3.0s), GET snapshot.
Integration tests own precise timing via FakeClock; Bruno uses sleep(3100) against dev server real clock.
}
script:pre-request {
const axios = require("axios");
const { resetPrototypeCombatTargets } = require("./scripts/combat-targets-reset-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));
}
await resetPrototypeCombatTargets(bru);
await axios.post(
`${baseUrl}/game/players/${playerId}/move`,
{ schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } },
jsonHeaders,
);
await axios.post(
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
{
schemaVersion: 1,
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
},
jsonHeaders,
);
await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_npc_melee" },
jsonHeaders,
);
const castResponse = await axios.post(
`${baseUrl}/game/players/${playerId}/ability-cast`,
{
schemaVersion: 1,
slotIndex: 0,
abilityId: "prototype_pulse",
targetId: "prototype_npc_melee",
},
jsonHeaders,
);
const castBody = castResponse.data;
if (!castBody?.accepted || !castBody?.combatResolution) {
throw new Error(`neo94 cast failed: ${JSON.stringify(castBody)}`);
}
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
await sleep(3100);
bru.setVar("neo94CastBody", JSON.stringify(castBody));
}
get {
url: {{baseUrl}}/game/world/npc-runtime-snapshot
body: none
auth: none
}
tests {
test("cast body shows accepted damaging pulse on melee", function () {
const castBody = JSON.parse(bru.getVar("neo94CastBody"));
expect(castBody.accepted).to.equal(true);
expect(castBody.combatResolution.targetRemainingHp).to.equal(75);
});
test("melee shows telegraph_windup with activeTelegraph after cooldown", function () {
const body = res.getBody();
const melee = body.npcInstances.find(
(x) => x.npcInstanceId === "prototype_npc_melee",
);
expect(melee).to.be.an("object");
expect(melee.state).to.equal("telegraph_windup");
expect(melee.aggroHolderPlayerId).to.equal("dev-local-1");
expect(melee.activeTelegraph).to.be.an("object");
expect(melee.activeTelegraph.archetypeKind).to.equal("melee_pressure");
expect(melee.activeTelegraph.windupRemainingSeconds).to.be.above(0);
expect(melee.activeTelegraph.npcInstanceId).to.equal("prototype_npc_melee");
});
test("non-target NPCs remain idle", function () {
const body = res.getBody();
const ranged = body.npcInstances.find(
(x) => x.npcInstanceId === "prototype_npc_ranged",
);
const elite = body.npcInstances.find(
(x) => x.npcInstanceId === "prototype_npc_elite",
);
expect(ranged.state).to.equal("idle");
expect(ranged.activeTelegraph).to.equal(null);
expect(elite.state).to.equal("idle");
expect(elite.activeTelegraph).to.equal(null);
});
}

View File

@ -0,0 +1,9 @@
meta {
name: npc-runtime-snapshot
}
docs {
NEO-94: GET /game/world/npc-runtime-snapshot — lazy AdvanceAll + runtime rows + active telegraphs.
Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution.
Telegraph smoke (seq 2) uses sleep(3100) against real dev server clock; integration tests use FakeClock.
}

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M2 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
## Purpose
@ -83,9 +83,13 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**NPC instance registry (NEO-91):** **`PrototypeNpcRegistry`** in `server/NeonSprawl.Server/Game/Npc/` — instance ids **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** with behavior bindings + world anchors; **`ICombatEntityHealthStore`** uses catalog **`maxHp`** per instance. Plan: [NEO-91 implementation plan](../../plans/NEO-91-implementation-plan.md). **Breaking:** Godot constants migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97).
**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md).
**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection: **`aggroHolderPlayerId`** on **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md).
**NPC runtime state machine (NEO-93):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** in `server/NeonSprawl.Server/Game/Npc/` — lazy tick advance for **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**; holder sync from **`IThreatStateStore`**; HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-93 implementation plan](../../plans/NEO-93-implementation-plan.md).
**NPC runtime state machine (NEO-93):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** in `server/NeonSprawl.Server/Game/Npc/` — lazy tick advance for **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**; holder sync from **`IThreatStateStore`**. HTTP projection: **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)). Plan: [NEO-93 implementation plan](../../plans/NEO-93-implementation-plan.md).
**NPC runtime snapshot HTTP (NEO-94):** **`GET /game/world/npc-runtime-snapshot`** — versioned read-only projection (`schemaVersion` **1**, **`npcInstances`**, optional nested **`activeTelegraph`**) with lazy **`AdvanceAll`** on poll. Plan: [NEO-94 implementation plan](../../plans/NEO-94-implementation-plan.md); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
**Session player combat HP (NEO-95):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`.
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation 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/`.

File diff suppressed because one or more lines are too long

View File

@ -268,9 +268,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria**
- [ ] Snapshot reflects state transition within one poll generation after aggro.
- [ ] Active telegraph row appears during windup with decreasing remaining time.
- [ ] Bruno smoke: lock NPC → damage → poll until telegraph row present.
- [x] Snapshot reflects state transition within one poll generation after aggro.
- [x] Active telegraph row appears during windup with decreasing remaining time.
- [x] Bruno smoke: lock NPC → damage → poll until telegraph row present.
**Landed ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)):** **`GET /game/world/npc-runtime-snapshot`** — **`NpcRuntimeSnapshotWorldApi`** + DTOs, lazy **`AdvanceAll`** on poll, nested **`activeTelegraph`**; plan [NEO-94-implementation-plan.md](NEO-94-implementation-plan.md).
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph label + NPC state HUD driven by this GET.
@ -295,9 +297,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria**
- [ ] Elite archetype telegraph → attack deals catalog damage to holder.
- [ ] Player HP read model matches store after NPC attack.
- [ ] No client-side damage math required for verification (Bruno or integration tests).
- [x] Elite archetype telegraph → attack deals catalog damage to holder.
- [x] Player HP read model matches store after NPC attack.
- [x] No client-side damage math required for verification (Bruno or integration tests).
**Landed ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog damage; **`GET /game/players/{id}/combat-health`**; plan [NEO-95-implementation-plan.md](NEO-95-implementation-plan.md).
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — player HP + incoming telegraph/attack feedback labels.

View File

@ -0,0 +1,166 @@
# NEO-94 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-94 |
| **Title** | E5M2-08: TelegraphEvent snapshot GET + wire DTOs |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-94/e5m2-08-telegraphevent-snapshot-get-wire-dtos |
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-08** |
| **Branch** | `NEO-94-npc-runtime-snapshot-get` |
| **Precursor** | [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) — NPC behavior state machine + lazy tick advance (**Done** on `main`) |
| **Pattern** | [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) — world GET + versioned DTOs; [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — lazy poll + `TimeProvider` + `serverTimeUtc` |
| **Blocks** | [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) — NPC attack resolve + player combat HP on snapshot |
| **Client counterpart** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph HUD + archetype markers (polls this GET); capstone [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Telegraph wire layout** | Nested on NPC row vs separate top-level array? | **Nested optional `activeTelegraph`** on each `npcInstances` row — matches E5M2-08 in-scope bullet; one array for client markers + HUD. | **User:** nested. |
| **Windup timing field** | `windupRemainingSeconds` vs `windupEndsAtUtc`? | **`windupRemainingSeconds`** — backlog AC + “no client windup math”; server computes from catalog + `TimeProvider`. | **User:** `windupRemainingSeconds`. |
| **Manual QA doc** | Add `docs/manual-qa/NEO-94.md`? | **Skip** — server-only HTTP; Bruno + integration tests (NEO-90/93 pattern). | **User:** skip. |
## Goal, scope, and out-of-scope
**Goal:** Expose **`GET /game/world/npc-runtime-snapshot`** — authoritative NPC runtime rows + active telegraphs for client poll (~1 Hz in combat). Handler invokes **`NpcRuntimeOperations.AdvanceAll`** before read (lazy tick).
**In scope (from Linear + [E5M2-08](E5M2-prototype-backlog.md#e5m2-08--telegraphevent-snapshot-get--wire-dtos)):**
- **`NpcRuntimeSnapshotWorldApi`** + DTOs projecting all three **`PrototypeNpcRegistry`** instances.
- Per-row fields: **`npcInstanceId`**, **`behaviorDefId`**, **`state`** (stable snake_case via **`NpcBehaviorStateWire`**), **`aggroHolderPlayerId`**, optional nested **`activeTelegraph`**.
- **`activeTelegraph`** when in **`telegraph_windup`**: **`telegraphId`**, **`npcInstanceId`**, **`windupRemainingSeconds`**, **`archetypeKind`** (from bound behavior def).
- Envelope: **`schemaVersion` 1**, **`serverTimeUtc`**, **`npcInstances`** array (ascending instance id order).
- Handler calls **`AdvanceAll(nowUtc, …)`** with default **5.0 s** delta cap before building the response.
- Integration tests (AAA) with **`InMemoryWebApplicationFactory.FakeClock`** — cast acquire → aggro → advance → telegraph with decreasing **`windupRemainingSeconds`**.
- Bruno folder **`bruno/neon-sprawl-server/npc-runtime-snapshot/`** — lock → damage → advance time → poll telegraph smoke.
- **`server/README.md`** route section; update threat/runtime “HTTP read” notes.
**Out of scope (from Linear + backlog):**
- Godot poll client ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
- Player damage / combat HP on snapshot ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
- Telemetry instrumentation ([NEO-96](https://linear.app/neon-sprawl/issue/NEO-96)).
- `docs/manual-qa/NEO-94.md` (kickoff decision).
## Acceptance criteria checklist
- [x] Snapshot reflects state transition within one poll generation after aggro (cast acquire → next GET shows **`aggro`** on locked NPC).
- [x] Active telegraph row appears during windup with decreasing **`windupRemainingSeconds`** across polls.
- [x] Bruno smoke: lock NPC → damage → poll until **`activeTelegraph`** present on melee row.
## Technical approach
### Handler flow
1. Resolve **`TimeProvider clock`**, **`INpcRuntimeStateStore`**, **`IThreatStateStore`**, **`INpcBehaviorDefinitionRegistry`** from DI.
2. `var now = clock.GetUtcNow()`.
3. **`NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry)`**.
4. Return **`Results.Json(BuildSnapshot(...))`** from a testable static **`BuildSnapshot`** (mirror **`CooldownSnapshotApi.BuildSnapshot`**).
### Response envelope (v1)
```json
{
"schemaVersion": 1,
"serverTimeUtc": "2026-04-30T12:00:03Z",
"npcInstances": [
{
"npcInstanceId": "prototype_npc_elite",
"behaviorDefId": "prototype_elite_mini_boss",
"state": "idle",
"aggroHolderPlayerId": null,
"activeTelegraph": null
}
]
}
```
During windup, **`activeTelegraph`** is non-null:
```json
"activeTelegraph": {
"telegraphId": "prototype_npc_melee:638…",
"npcInstanceId": "prototype_npc_melee",
"windupRemainingSeconds": 1.2,
"archetypeKind": "melee_pressure"
}
```
**`windupRemainingSeconds`** = `max(0, behaviorDef.telegraphWindupSeconds - (nowUtc - windupStartedUtc).TotalSeconds)` — clamp at zero; omit telegraph object when state ≠ **`telegraph_windup`** (even if internal row still winding down within same advance step — projection reads post-**`AdvanceAll`** store).
### Row assembly (per prototype instance id)
| Source | Field |
|--------|--------|
| **`PrototypeNpcRegistry`** | **`npcInstanceId`**, **`behaviorDefId`** |
| **`IThreatStateStore`** | **`aggroHolderPlayerId`** |
| **`INpcRuntimeStateStore`** | **`state`** via **`NpcBehaviorStateWire.ToWireName`** |
| Runtime row + behavior def | **`activeTelegraph`** when **`TelegraphWindup`** |
All three instances always present (same contract shape as **`GET /game/world/combat-targets`**).
### Lazy tick on GET
- First GET after process start: **`AdvanceAll`** initializes rows (NEO-93 **`LastAdvancedUtc == MinValue`** path).
- Subsequent GETs: delta capped at **5.0 s**; **`FakeClock.Advance`** in tests drives telegraph timing without real sleeps.
- Cast does **not** advance NPC runtime — only snapshot GET (and future stories that explicitly call **`AdvanceAll`**).
### Bruno smoke (E5M2-08 AC)
Pre-request script pattern (from **`combat-targets/Get combat targets after one cast.bru`**):
1. Reset fixture (**`combat-targets-reset-helper.js`**).
2. Bind slot 0 **`prototype_pulse`**, lock **`prototype_npc_melee`**, damaging cast.
3. Advance fake/server time **≥ 3.0 s** (melee **`attackCooldownSeconds`**) — Bruno uses real clock against dev server; test asserts telegraph within a follow-up GET after wait or documents fixed **`sleep`** if needed for local Bruno (integration tests use **`FakeClock`** as source of truth).
4. GET **`/game/world/npc-runtime-snapshot`** — assert melee **`state === "telegraph_windup"`** and **`activeTelegraph.windupRemainingSeconds > 0`**.
### Example timeline (melee, integration test with **`FakeClock`**)
```
t=0 cast (acquire holder) + GET → melee state "aggro", activeTelegraph null
t=3 GET (AdvanceAll) → "telegraph_windup", windupRemainingSeconds ≈ 1.5
t=4 GET → windupRemainingSeconds ≈ 0.5 (decreasing)
t=4.5 GET → state advances past windup (attack_execute/recover); activeTelegraph null
```
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | **`GET /game/world/npc-runtime-snapshot`** — **`AdvanceAll`** + **`BuildSnapshot`**. |
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotDtos.cs` | Response envelope + **`NpcInstanceRuntimeJson`** + **`ActiveTelegraphJson`**. |
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeSnapshotWorldApiTests.cs` | AAA integration tests (schema, idle rows, cast→aggro, advance→telegraph, decreasing remaining). |
| `bruno/neon-sprawl-server/npc-runtime-snapshot/folder.bru` | Bruno folder metadata. |
| `bruno/neon-sprawl-server/npc-runtime-snapshot/Get npc runtime snapshot.bru` | Happy GET — schema v1 + three instances. |
| `bruno/neon-sprawl-server/npc-runtime-snapshot/Get snapshot after cast telegraph.bru` | Lock → damage → poll telegraph smoke (E5M2-08 AC). |
| `docs/plans/NEO-94-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register **`app.MapNpcRuntimeSnapshotWorldApi()`** alongside other world GET routes. |
| `server/README.md` | Add **`GET /game/world/npc-runtime-snapshot`** section; replace “HTTP read | Not exposed yet” in threat + runtime tables. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | NEO-94 landed note + HTTP read model cross-link when complete. |
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-08 acceptance checkboxes + landed note when complete. |
## Tests
| File | Coverage |
|------|----------|
| **`NpcRuntimeSnapshotWorldApiTests.cs`** | **Idle baseline:** GET returns **`schemaVersion` 1**, **`serverTimeUtc`**, three **`npcInstances`** in ascending id order; all **`idle`**, null holders, null **`activeTelegraph`**. **Cast → aggro:** after damaging cast on melee, immediate GET shows melee **`state`** **`aggro`**, **`aggroHolderPlayerId`** **`dev-local-1`**, no telegraph. **Advance → telegraph:** advance **`FakeClock`** by **3.0 s**, GET shows **`telegraph_windup`** + **`activeTelegraph`** with **`archetypeKind`** **`melee_pressure`** and **`windupRemainingSeconds`** ≈ **1.5**. **Decreasing remaining:** advance **1.0 s**, second GET shows lower **`windupRemainingSeconds`**. **Non-target NPCs unchanged:** ranged/elite stay **`idle`** in cast scenario. |
| **Bruno `npc-runtime-snapshot/`** | CI Bruno step — happy GET + cast→telegraph smoke per E5M2-08 AC. |
No `docs/manual-qa/NEO-94.md`. No Godot tests.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|---------------------|--------|
| **Linear blockedBy NEO-93** | Proceed — NEO-93 **Done** on `main` (PR #131). | **adopted** |
| **Bruno real-time wait vs FakeClock** | Integration tests own timing precision; Bruno against dev server may use short **`sleep(3100)`** in pre-request or poll loop — document in `.bru` docs block. | **adopted** |
| **`behaviorDefId` on row** | Include — static from **`PrototypeNpcRegistry`**; saves NEO-97 from extra behavior-definitions join for archetype markers. | **adopted** |
| **Player HP on snapshot** | Defer to NEO-95 — this slice exposes NPC runtime + telegraphs only. | **deferred** |
| **Top-level `telegraphEvents[]`** | Rejected at kickoff — nested **`activeTelegraph`** only. | **adopted** (kickoff) |

View File

@ -0,0 +1,176 @@
# NEO-95 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-95 |
| **Title** | E5M2-09: NPC attack resolve + session player combat HP |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-95/e5m2-09-npc-attack-resolve-session-player-combat-hp |
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-09** |
| **Branch** | `NEO-95-npc-attack-resolve-player-hp` |
| **Precursor** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) — TelegraphEvent snapshot GET + wire DTOs (**Done** on `main`) |
| **Pattern** | [NEO-80/91](https://linear.app/neon-sprawl/issue/NEO-91) — `ICombatEntityHealthStore` for NPC HP; [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — player-scoped GET snapshot (`cooldown-snapshot`) |
| **Blocks** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph HUD + player HP labels (polls this GET + npc-runtime-snapshot) |
| **Client counterpart** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — player HP + incoming telegraph/attack feedback; capstone [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Player HP read model** | Nested on npc-runtime-snapshot vs dedicated player GET? | **`GET /game/players/{id}/combat-health`** — player-scoped route mirrors `cooldown-snapshot`; keeps world snapshot NPC-only. | **User:** dedicated GET. |
| **Manual QA doc** | Add `docs/manual-qa/NEO-95.md`? | **Skip** — server-only HTTP; Bruno + integration tests (NEO-94 pattern). | **User:** skip. |
## Goal, scope, and out-of-scope
**Goal:** When an NPC telegraph completes during lazy tick advance, apply catalog **`attackDamage`** to the aggro holder via **`IPlayerCombatHealthStore`**. Expose session player combat HP on a player GET for client HUD poll.
**In scope (from Linear + [E5M2-09](E5M2-prototype-backlog.md#e5m2-09--npc-attack-resolve--session-player-combat-hp)):**
- **`IPlayerCombatHealthStore`** + in-memory implementation (session-only, lazy init **100** HP).
- **`NpcAttackOperations.TryResolveTelegraphComplete`** invoked from **`NpcRuntimeOperations.AdvanceAll`** when windup completes.
- **`GET /game/players/{id}/combat-health`** — **`schemaVersion` 1**, **`playerId`**, **`maxHp`**, **`currentHp`**, **`defeated`**; **404** when player has no position row (same gate as cooldown-snapshot).
- Holder re-check at damage time — skip damage if aggro holder cleared during windup (leash clear).
- Comment-only **`player_death`** hook site when HP reaches **0** (no respawn loop).
- Extend dev **`POST /game/__dev/combat-targets-fixture`** to reset dev player combat HP to full.
- Integration tests (AAA) with **`InMemoryWebApplicationFactory.FakeClock`** — elite telegraph complete → holder HP **100 → 75**; GET read model matches store.
- Bruno folder **`bruno/neon-sprawl-server/combat-health/`** — baseline GET + post-attack HP smoke.
- **`server/README.md`** player combat HP section; update NPC runtime “attack_execute stub” note.
**Out of scope (from Linear + backlog):**
- Godot HUD ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
- Postgres persistence, healing, player defensive abilities.
- Telemetry instrumentation ([NEO-96](https://linear.app/neon-sprawl/issue/NEO-96)).
- `docs/manual-qa/NEO-95.md` (kickoff decision).
## Acceptance criteria checklist
- [x] Elite archetype telegraph → attack deals catalog damage (**25**) to holder.
- [x] Player HP read model matches store after NPC attack.
- [x] No client-side damage math required for verification (Bruno or integration tests).
## Technical approach
### Damage resolve flow
1. **`NpcRuntimeOperations.AdvanceOne`** — on **`TelegraphWindup`** phase completion (transition to **`Recover`**), call **`NpcAttackOperations.TryResolveTelegraphComplete`** **before** writing the new state.
2. **`TryResolveTelegraphComplete(npcInstanceId, holderPlayerId, attackDamage, playerHealthStore, threatStore)`**:
- Re-verify holder via **`IThreatStateStore`** — if holder missing or differs, **no-op** (leash clear during windup).
- **`playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out _)`**.
- When resulting **`currentHp == 0`**: comment-only **`// TODO(E9.M1): player_death`** (mirror NEO-84 combat hooks).
3. **`AdvanceAll`** gains optional **`IPlayerCombatHealthStore`** parameter (required in production callers — snapshot GET and tests pass the store from DI).
### Player combat health store
Mirror **`ICombatEntityHealthStore`** shape but keyed by **player id** (not NPC instance id):
| Method | Behavior |
|--------|----------|
| **`TryGet`** | Lazy-init **`currentHp = maxHp = 100`** on first access for any non-empty normalized player id. |
| **`TryApplyDamage`** | Non-negative damage; floor at zero; return snapshot with **`defeated`** when **`currentHp == 0`**. |
| **`TryResetToFull`** | Restore to **100** HP; create row if absent (dev fixture + tests). |
**Constant:** **`PlayerCombatHealthDefaults.MaxHp = 100`** — flat prototype default per E5M2 kickoff table (not catalog-derived).
### HTTP read model
**`GET /game/players/{id}/combat-health`** (NEO-95):
```json
{
"schemaVersion": 1,
"playerId": "dev-local-1",
"maxHp": 100,
"currentHp": 75,
"defeated": false
}
```
- Gate: **`IPositionStateStore.TryGetPosition(id)`** — **404** when unknown player (same as **`CooldownSnapshotApi`**).
- Lazy-init on GET — first poll returns full **100** HP without requiring prior NPC attack.
- **`NpcRuntimeSnapshotWorldApi`** unchanged — NPC-only world poll; NEO-97 polls both endpoints at ~1 Hz.
### Lazy tick wiring
**`NpcRuntimeSnapshotWorldApi`** handler injects **`IPlayerCombatHealthStore`** and passes it to **`AdvanceAll`** so poll-driven advance applies damage before building the NPC snapshot response.
### Example timeline (elite, integration test with **`FakeClock`**)
Elite: **`attackCooldownSeconds` 5.0**, **`telegraphWindupSeconds` 2.5**, **`attackDamage` 25**.
```
t=0 lock elite + damaging cast → GET combat-health currentHp 100
t=0 GET npc-runtime-snapshot → elite state "aggro"
t=5 GET npc-runtime-snapshot (AdvanceAll) → "telegraph_windup"
t=7.5 GET npc-runtime-snapshot (AdvanceAll) → windup complete, damage applied, state "recover"
GET combat-health → currentHp 75, defeated false
```
### Dev fixture extension
**`CombatTargetFixtureApi`** — after NPC HP + threat + runtime reset, call **`playerHealthStore.TryResetToFull(devPlayerId)`** using **`Game:DevPlayerId`** from **`IOptions<GamePositionOptions>`** so Bruno smoke starts at full player HP.
### Bruno smoke (E5M2-09 AC)
1. Reset fixture (**`combat-targets-reset-helper.js`**).
2. Bind slot 0 **`prototype_pulse`**, lock **`prototype_npc_elite`**, damaging cast.
3. Poll **`GET /game/world/npc-runtime-snapshot`** after cast, wait **≥ 5.0 s**, poll again, wait **≥ 2.5 s**, poll again (lazy **`AdvanceAll`** delta cap **5.0 s** — single sleep is insufficient for elite **7.5 s** cycle).
4. **`GET /game/players/dev-local-1/combat-health`** — assert **`currentHp === 75`**.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs` | Session player HP contract (TryGet / TryApplyDamage / TryResetToFull). |
| `server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs` | Immutable snapshot record (`playerId`, `maxHp`, `currentHp`, `defeated`). |
| `server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs` | **`MaxHp = 100`** prototype constant. |
| `server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs` | Thread-safe in-memory implementation. |
| `server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs` | DI registration singleton. |
| `server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs` | **`TryResolveTelegraphComplete`** — holder re-check + damage apply + death hook comment. |
| `server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs` | **`GET /game/players/{id}/combat-health`**. |
| `server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs` | Response envelope JSON DTO. |
| `server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs` | Unit tests — lazy init, damage floor, reset, defeated flag. |
| `server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs` | Unit tests — resolve applies damage; no-op when holder cleared. |
| `server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs` | Integration tests — schema v1 baseline, elite telegraph → HP 75, 404 unknown player. |
| `bruno/neon-sprawl-server/combat-health/folder.bru` | Bruno folder metadata. |
| `bruno/neon-sprawl-server/combat-health/Get player combat health.bru` | Happy GET — schema v1 + full HP baseline. |
| `bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru` | Lock elite → cast → poll → assert HP decreased. |
| `docs/plans/NEO-95-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | Pass **`IPlayerCombatHealthStore`** into **`AdvanceAll`** / **`AdvanceOne`**; invoke **`NpcAttackOperations.TryResolveTelegraphComplete`** on telegraph completion. |
| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | Inject **`IPlayerCombatHealthStore`**; pass to **`AdvanceAll`** on poll. |
| `server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs` | Reset dev player combat HP on fixture POST. |
| `server/NeonSprawl.Server/Program.cs` | Register **`AddPlayerCombatHealthStore()`** + **`MapPlayerCombatHealthApi()`**. |
| `server/README.md` | Add **`GET /game/players/{id}/combat-health`** section; update attack_execute stub + fixture reset notes. |
| `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs` | Pass null or test store into updated **`AdvanceAll`** signature; add test that windup completion applies player damage. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | NEO-95 landed note + player combat HP cross-link when complete. |
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-09 acceptance checkboxes + landed note when complete. |
## Tests
| File | Coverage |
|------|----------|
| **`PlayerCombatHealthStoreTests.cs`** | **Lazy init:** first **`TryGet`** returns **`maxHp`/`currentHp` 100**, not defeated. **Damage:** **`TryApplyDamage(15)`** → **85** HP. **Floor:** damage below zero HP → **0**, **`defeated` true**. **Reset:** **`TryResetToFull`** restores 100. **Invalid:** empty player id returns false. |
| **`NpcAttackOperationsTests.cs`** | **Happy path:** holder set → **`TryResolveTelegraphComplete`** applies catalog damage. **Holder cleared:** leash clear before resolve → no HP mutation. **Zero damage:** no-op success. |
| **`NpcRuntimeOperationsTests.cs`** | **Windup → damage:** holder on melee, advance through windup with player store → holder HP decreased by **15**. |
| **`PlayerCombatHealthApiTests.cs`** | **Baseline GET:** **`dev-local-1`** returns schema v1, **100/100**, not defeated. **Elite attack (AC):** lock elite, cast, advance **`FakeClock`** **7.5 s**, GET snapshot then GET combat-health → **`currentHp` 75**. **Read model match:** store **`TryGet`** equals HTTP body. **404:** unknown player id. |
| **Bruno `combat-health/`** | CI Bruno step — baseline GET + elite attack HP smoke per E5M2-09 AC. |
No `docs/manual-qa/NEO-95.md`. No Godot tests.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|---------------------|--------|
| **Linear blockedBy NEO-94** | Proceed — NEO-94 **Done** on `main`. | **adopted** |
| **Player HP on world snapshot** | Rejected at kickoff — dedicated player GET only. | **adopted** (kickoff) |
| **Holder cleared mid-windup** | Skip damage at resolve time if threat row empty or holder mismatch. | **adopted** |
| **Multiple NPCs same holder** | Each telegraph completion applies its own **`attackDamage`** independently — deterministic, no cap in prototype. | **adopted** |
| **`AdvanceAll` signature change** | Add **`IPlayerCombatHealthStore?`** or required param; update snapshot GET + all tests. | **adopted** |
| **`player_death` telemetry** | Comment-only hook at HP **0**; full event wiring deferred to E9.M1 / future player death story. | **adopted** |
| **Bruno real-time wait** | Integration tests own timing precision; Bruno must **multi-poll** **`npc-runtime-snapshot`** (5s + 2.5s waits) before combat-health GET — **`AdvanceAll`** delta cap prevents single-sleep timeline. | **adopted** |

View File

@ -0,0 +1,66 @@
# Code review — NEO-94 (E5M2-08)
**Date:** 2026-05-28
**Scope:** Branch `NEO-94-npc-runtime-snapshot-get` vs `origin/main` — full rewrite in `a68d8aa` (+ plan `5442910`)
**Base:** `origin/main`
**Follow-up:** Suggestions addressed in `798c601`; re-verified 2026-05-28 (NEO-94 tests 4/4 pass).
## Verdict
**Approve**
## Summary
The rewritten branch delivers E5M2-08 cleanly: **`GET /game/world/npc-runtime-snapshot`** invokes **`NpcRuntimeOperations.AdvanceAll`** on poll, then projects all three **`PrototypeNpcRegistry`** rows with **`behaviorDefId`**, **`state`**, **`aggroHolderPlayerId`**, and optional nested **`activeTelegraph`** (including server-computed **`windupRemainingSeconds`**). The implementation mirrors **`CooldownSnapshotApi`** / **`CombatTargetsWorldApi`** conventions — `internal static BuildSnapshot`, fail-fast registry lookups, versioned envelope with **`serverTimeUtc`**, route registered in **`Program.cs`**. Four AAA integration tests cover idle baseline, cast→aggro, advance→telegraph, and decreasing windup; Bruno adds happy-path + telegraph smoke with documented **`sleep(3100)`**. Build succeeds; NEO-94-filtered tests pass (**4/4**). **`NpcRuntimeStateSnapshot`** is unchanged — projection reads from registry + threat store at HTTP boundary, as planned.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-94-implementation-plan.md` | **Matches** — AC checkboxes marked done; handler flow, wire shape, lazy tick, and file list align with the diff. |
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-08) | **Matches** — AC checked, landed note present. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — E5M2-08 landed in status table; NEO-94 HTTP section + README/Bruno cross-links. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-94 landed** (snapshot GET, Bruno folder, plan/README links). |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server-owned runtime + telegraph timing; client poll-only (NEO-97 deferred). |
| `server/README.md` | **Matches** — threat/runtime HTTP read rows updated; dedicated NEO-94 section with field table and curl. |
## Blocking issues
None.
## Suggestions
1. ~~**Update `documentation_and_implementation_alignment.md`** — Append **NEO-94 landed** to the E5.M2 tracking row (snapshot GET, Bruno folder, plan/README links), matching NEO-90NEO-93 entries. Listed in the plan “Files to modify” but not yet in the diff.~~ **Done.** (`798c601`)
2. ~~**AAA layout in two integration tests** — `GetNpcRuntimeSnapshot_ShouldShowAggro_OnMeleeAfterDamagingCast` and `GetNpcRuntimeSnapshot_ShouldShowTelegraph_AfterAttackCooldownElapses` call **`ReadFromJsonAsync`** in **Act**. Move status/body reads to **Assert** to match **`CombatTargetsWorldApiTests`** and [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert):
```csharp
// Act
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
```~~ **Done.** Aggro + telegraph tests fixed; decreasing-windup test also moves all **`ReadFromJsonAsync`** calls to **Assert** (`798c601`).
## Nits
- Nit: **`ActiveTelegraph`** / **`AggroHolderPlayerId`** could use **`[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`** for leaner JSON (optional; explicit `null` is fine for client parsers).
- ~~Nit: **`GetNpcRuntimeSnapshot_ShouldDecreaseWindupRemaining_AcrossPolls`** captures **`firstBody`** during **Arrange** (baseline for comparison). Acceptable for a two-poll delta test; alternatively split into two facts for stricter AAA.~~ **Done.** **`ReadFromJsonAsync`** for both polls now in **Assert**; first poll GET remains in **Arrange** as setup (`798c601`).
## Verification
```bash
# Build + NEO-94 tests (passed 2026-05-28)
dotnet build NeonSprawl.sln
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~NpcRuntimeSnapshot"
# Full server suite (Postgres persistence tests require local DB; 12 env failures unrelated to NEO-94)
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
# Bruno (CI or local dev server)
# bruno/neon-sprawl-server/npc-runtime-snapshot/
```
Manual: run **`Get snapshot after cast telegraph.bru`** against dev server — melee should reach **`telegraph_windup`** with **`activeTelegraph.windupRemainingSeconds > 0`** after the 3.1 s wait.

View File

@ -0,0 +1,61 @@
# Code review — NEO-95 (E5M2-09)
**Date:** 2026-05-28
**Scope:** Branch `NEO-95-npc-attack-resolve-player-hp` vs `origin/main` — commits `ac4e1df``b702d9f`
**Base:** `origin/main`
**Follow-up:** Suggestions and nits addressed in review follow-up commit; re-verified 2026-05-28.
## Verdict
**Approve**
## Summary
NEO-95 delivers the server slice cleanly: **`IPlayerCombatHealthStore`** (lazy-init 100 HP, damage floor, dev reset) mirrors the NPC **`ICombatEntityHealthStore`** pattern; **`NpcAttackOperations.TryResolveTelegraphComplete`** re-checks the threat holder before applying catalog **`attackDamage`**; **`NpcRuntimeOperations.AdvanceAll`** invokes resolve at telegraph windup completion and passes the store from **`NpcRuntimeSnapshotWorldApi`** poll; **`GET /game/players/{id}/combat-health`** gates on position row (same as cooldown snapshot) and returns schema v1 JSON. Dev fixture reset restores player HP. Tests cover store unit behavior, resolve no-ops, runtime windup→damage wiring, integration elite timeline (FakeClock 7.5 s → **75** HP), fixture reset, and Bruno smoke with documented **`sleep(8000)`**. Build succeeds; NEO-95-filtered tests pass (**40/40**). Server-only scope — client HUD correctly deferred to NEO-97.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-95-implementation-plan.md` | **Matches** — AC checkboxes done; dedicated GET (kickoff), damage flow, holder re-check, fixture reset, tests, and file list align with the diff. |
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-09) | **Matches** — AC checked, landed note present; client counterpart NEO-97 called out. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — NEO-95 implementation section + README/Bruno links; Summary **Status** marks E5M2-09 landed. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-95 landed** (`IPlayerCombatHealthStore`, combat-health GET, Bruno folder, plan/README links). |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server owns NPC→player damage; client poll-only read model; no client damage math. |
| `server/README.md` | **Matches** — session player combat HP section, GET route, fixture reset note, npc-runtime snapshot cross-link updated. |
## Blocking issues
None.
## Suggestions
1. ~~**Update `documentation_and_implementation_alignment.md`** — Append **NEO-95 landed** to the E5.M2 tracking row (`IPlayerCombatHealthStore`, `NpcAttackOperations`, `GET …/combat-health`, Bruno `combat-health/`, plan/README links), matching NEO-87NEO-94 entries. Listed implicitly by review checklist; not in the NEO-95 plan “Files to modify” table but keeps alignment doc truthful.~~ **Done.** (`5efa5a4`)
2. ~~**E5.M2 Summary status table** — Mark **E5M2-09 / NEO-95 landed** inline in the module Summary **Status** row (currently jumps from “NEO-94 runtime snapshot GET landed” to “NEO-95 NPC attack resolve + player combat HP → E5M2-12” without “landed”).~~ **Done.** (`5efa5a4`)
## Nits
- ~~Nit: **`NpcBehaviorState.AttackExecute`** branch in `NpcRuntimeOperations` is unreachable in the normal windup→recover flow (NEO-93); harmless defensive path but could carry a one-line comment or be removed until a visible execute phase is needed.~~ **Done.** — one-line comment added.
- ~~Nit: **`PlayerCombatHealthApi.BuildResponse`** echoes normalized lowercase **`snapshot.PlayerId`** while **`CooldownSnapshotApi`** echoes the route `{id}` as provided — consistent for prototype lowercase ids; note if mixed-case route ids become a concern.~~ **Done.****`BuildResponse`** now echoes route **`{id}`** like cooldown snapshot.
- ~~Nit: Elite integration test **`GetPlayerCombatHealth_ShouldDecreaseAfterEliteTelegraphComplete`** performs setup GETs in **Arrange** (acceptable for multi-poll timeline); baseline/mid snapshot reads could move to **Assert** if stricter AAA parity with NEO-94 follow-up is desired.~~ **Done.** — poll GETs kept in **Arrange** as timeline drivers only; **`ReadFromJsonAsync`** verification confined to **Act**/**Assert**.
## Verification
```bash
# Build + NEO-95-related tests (passed 2026-05-28)
dotnet build NeonSprawl.sln
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~PlayerCombatHealth|FullyQualifiedName~NpcAttackOperations|FullyQualifiedName~CombatTargetFixture|FullyQualifiedName~NpcRuntimeOperations"
# Full server suite (Postgres persistence tests require local DB)
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
# Bruno (CI runs entire bruno/neon-sprawl-server/)
# bruno/neon-sprawl-server/combat-health/
```
Manual: run **`Get combat health after elite attack.bru`** against dev server — after ~8 s wait, **`currentHp`** should be **75** with elite cast accepted on **`prototype_npc_elite`**.

View File

@ -22,7 +22,9 @@ public sealed class CombatTargetFixtureApiTests
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
var playerHealthStore = factory.Services.GetRequiredService<IPlayerCombatHealthStore>();
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
_ = playerHealthStore.TryApplyDamage("dev-local-1", 40, out _);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
_ = runtimeStore.TryWrite(
@ -54,6 +56,9 @@ public sealed class CombatTargetFixtureApiTests
runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime);
Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState);
Assert.Null(runtime.ActiveTelegraph);
playerHealthStore.TryGet("dev-local-1", out var playerHealth);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, playerHealth.CurrentHp);
Assert.False(playerHealth.Defeated);
}
[Fact]

View File

@ -0,0 +1,89 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class NpcAttackOperationsTests
{
[Fact]
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches()
{
// Arrange
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
playerHealthStore,
threatStore);
// Assert
Assert.True(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(85, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared()
{
// Arrange
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
_ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
playerHealthStore,
threatStore);
// Assert
Assert.False(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers()
{
// Arrange
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
playerHealthStore,
threatStore);
// Assert
Assert.False(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero()
{
// Arrange
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 0,
playerHealthStore,
threatStore);
// Assert
Assert.True(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
}

View File

@ -0,0 +1,132 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class PlayerCombatHealthApiTests
{
private static async Task MoveDevPlayerNearEliteAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move",
new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = -2, Y = 0.9, Z = -2 },
});
response.EnsureSuccessStatusCode();
}
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 LockPrototypeNpcEliteAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcEliteId,
});
response.EnsureSuccessStatusCode();
}
private static AbilityCastRequest PulseCastRequest() =>
new()
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeNpcRegistry.PrototypeNpcEliteId,
};
[Fact]
public async Task GetPlayerCombatHealth_ShouldReturnSchemaV1_WithFullHpBaseline()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/dev-local-1/combat-health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PlayerCombatHealthResponse>();
Assert.NotNull(body);
Assert.Equal(PlayerCombatHealthResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal("dev-local-1", body.PlayerId);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, body.MaxHp);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, body.CurrentHp);
Assert.False(body.Defeated);
}
[Fact]
public async Task GetPlayerCombatHealth_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/combat-health");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetPlayerCombatHealth_ShouldDecreaseAfterEliteTelegraphComplete()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await MoveDevPlayerNearEliteAsync(client);
await LockPrototypeNpcEliteAsync(client);
var castResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
PulseCastRequest());
castResponse.EnsureSuccessStatusCode();
_ = await client.GetAsync("/game/world/npc-runtime-snapshot");
factory.FakeClock!.Advance(TimeSpan.FromSeconds(5));
_ = await client.GetAsync("/game/world/npc-runtime-snapshot");
factory.FakeClock.Advance(TimeSpan.FromSeconds(2.5));
// Act
var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
var healthResponse = await client.GetAsync("/game/players/dev-local-1/combat-health");
// Assert
snapshotResponse.EnsureSuccessStatusCode();
healthResponse.EnsureSuccessStatusCode();
var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
var healthBody = await healthResponse.Content.ReadFromJsonAsync<PlayerCombatHealthResponse>();
Assert.NotNull(snapshotBody);
Assert.NotNull(healthBody);
var elite = snapshotBody!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcEliteId);
Assert.Equal("recover", elite.State);
Assert.Equal(75, healthBody!.CurrentHp);
Assert.False(healthBody.Defeated);
Assert.Equal("dev-local-1", healthBody.PlayerId);
var store = factory.Services.GetRequiredService<IPlayerCombatHealthStore>();
store.TryGet("dev-local-1", out var storeSnapshot);
Assert.Equal(healthBody.CurrentHp, storeSnapshot.CurrentHp);
Assert.Equal(healthBody.MaxHp, storeSnapshot.MaxHp);
Assert.Equal(healthBody.Defeated, storeSnapshot.Defeated);
}
}

View File

@ -0,0 +1,118 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class PlayerCombatHealthStoreTests
{
[Fact]
public void TryGet_ShouldLazyInitToMaxHp_OnFirstAccess()
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
// Act
var ok = store.TryGet("dev-local-1", out var snapshot);
// Assert
Assert.True(ok);
Assert.Equal("dev-local-1", snapshot.PlayerId);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.MaxHp);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
Assert.False(snapshot.Defeated);
}
[Fact]
public void TryApplyDamage_ShouldDecreaseHp_AndFloorAtZero()
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
// Act
var ok = store.TryApplyDamage("dev-local-1", 15, out var snapshot);
// Assert
Assert.True(ok);
Assert.Equal(85, snapshot.CurrentHp);
Assert.False(snapshot.Defeated);
}
[Fact]
public void TryApplyDamage_ShouldMarkDefeated_WhenHpReachesZero()
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
// Act
var ok = store.TryApplyDamage("dev-local-1", 100, out var snapshot);
// Assert
Assert.True(ok);
Assert.Equal(0, snapshot.CurrentHp);
Assert.True(snapshot.Defeated);
}
[Fact]
public void TryApplyDamage_ShouldKeepHpAtZero_WhenAlreadyDefeated()
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
_ = store.TryApplyDamage("dev-local-1", 100, out _);
// Act
var ok = store.TryApplyDamage("dev-local-1", 25, out var snapshot);
// Assert
Assert.True(ok);
Assert.Equal(0, snapshot.CurrentHp);
Assert.True(snapshot.Defeated);
}
[Fact]
public void TryResetToFull_ShouldRestoreMaxHp()
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
_ = store.TryApplyDamage("dev-local-1", 40, out _);
// Act
var ok = store.TryResetToFull("dev-local-1", out var snapshot);
// Assert
Assert.True(ok);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
Assert.False(snapshot.Defeated);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryGet_ShouldReturnFalse_WhenPlayerIdInvalid(string? playerId)
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
// Act
var ok = store.TryGet(playerId, out var snapshot);
// Assert
Assert.False(ok);
Assert.Equal(default, snapshot);
}
[Fact]
public void TryApplyDamage_ShouldReturnFalse_WhenDamageNegative()
{
// Arrange
var store = new InMemoryPlayerCombatHealthStore();
// Act
var ok = store.TryApplyDamage("dev-local-1", -1, out var snapshot);
// Assert
Assert.False(ok);
Assert.Equal(default, snapshot);
}
[Fact]
public async Task Host_ShouldResolvePlayerCombatHealthStoreFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
// Act
var store = factory.Services.GetRequiredService<IPlayerCombatHealthStore>();
// Assert
Assert.NotNull(store);
Assert.True(store.TryGet("dev-local-1", out var snapshot));
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using Xunit;
@ -8,13 +9,14 @@ public sealed class NpcRuntimeOperationsTests
private static readonly DateTimeOffset T0 =
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior)
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IPlayerCombatHealthStore PlayerHealth)
CreateFixture()
{
return (
new InMemoryNpcRuntimeStateStore(),
new InMemoryThreatStateStore(),
PrototypeNpcTestFixtures.CreateBehaviorRegistry());
PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
new InMemoryPlayerCombatHealthStore());
}
private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") =>
@ -25,16 +27,23 @@ public sealed class NpcRuntimeOperationsTests
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds);
NpcRuntimeOperations.AdvanceAll(
nowUtc,
runtimeStore,
threatStore,
behaviorRegistry,
playerHealthStore,
maxDeltaSeconds);
[Fact]
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
// Act
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -45,10 +54,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -60,10 +69,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
runtime.LastAdvancedUtc = T0;
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior);
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -74,11 +83,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -90,11 +99,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(2.9), runtime, threat, behavior);
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -104,12 +113,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -119,13 +128,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -137,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior);
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -155,11 +164,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, maxDeltaSeconds: 10);
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 10);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -171,13 +180,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(3.5), runtime, threat, behavior);
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -192,11 +201,11 @@ public sealed class NpcRuntimeOperationsTests
double attackCooldownSeconds)
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -212,12 +221,12 @@ public sealed class NpcRuntimeOperationsTests
double telegraphWindupSeconds)
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior);
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -230,11 +239,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5);
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -246,12 +255,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(100), runtime, threat, behavior, maxDeltaSeconds: 5);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
// Act
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, maxDeltaSeconds: 5);
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
@ -263,14 +272,14 @@ public sealed class NpcRuntimeOperationsTests
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0, runtime, threat, behavior, playerHealth);
runtime.LastAdvancedUtc = T0.AddHours(1);
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5);
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -282,15 +291,49 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
Advance(T0, runtime, threat, behavior);
var (runtime, threat, behavior, playerHealth) = CreateFixture();
Advance(T0, runtime, threat, behavior, playerHealth);
runtime.LastAdvancedUtc = T0.AddSeconds(10);
// Act
Advance(T0.AddSeconds(5), runtime, threat, behavior);
Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
}
[Fact]
public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(85, snapshot.CurrentHp);
}
[Fact]
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth);
// Assert
playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeSnapshot);
Assert.Equal(NpcBehaviorState.Idle, runtimeSnapshot.BehaviorState);
}
[Fact]
public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings()
{

View File

@ -0,0 +1,175 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
public sealed class NpcRuntimeSnapshotWorldApiTests
{
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 LockPrototypeNpcMeleeAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
response.EnsureSuccessStatusCode();
}
private static AbilityCastRequest PulseCastRequest() =>
new()
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
};
[Fact]
public async Task GetNpcRuntimeSnapshot_ShouldReturnSchemaV1_WithThreeIdleInstances()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
Assert.NotNull(body);
Assert.Equal(NpcRuntimeSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotEqual(default, body.ServerTimeUtc);
Assert.NotNull(body.NpcInstances);
Assert.Equal(3, body.NpcInstances.Count);
var ids = body.NpcInstances.Select(static row => row.NpcInstanceId).ToList();
Assert.Equal(PrototypeNpcRegistryTests.InstancesInIdOrder, ids);
foreach (var row in body.NpcInstances)
{
Assert.Equal("idle", row.State);
Assert.Null(row.AggroHolderPlayerId);
Assert.Null(row.ActiveTelegraph);
}
var melee = body.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, melee.BehaviorDefId);
}
[Fact]
public async Task GetNpcRuntimeSnapshot_ShouldShowAggro_OnMeleeAfterDamagingCast()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await BindSlot0PulseAsync(client);
await LockPrototypeNpcMeleeAsync(client);
var castResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
PulseCastRequest());
castResponse.EnsureSuccessStatusCode();
// Act
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
Assert.NotNull(body);
var melee = body!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal("aggro", melee.State);
Assert.Equal("dev-local-1", melee.AggroHolderPlayerId);
Assert.Null(melee.ActiveTelegraph);
var ranged = body.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcRangedId);
Assert.Equal("idle", ranged.State);
Assert.Null(ranged.AggroHolderPlayerId);
Assert.Null(ranged.ActiveTelegraph);
var elite = body.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcEliteId);
Assert.Equal("idle", elite.State);
Assert.Null(elite.AggroHolderPlayerId);
Assert.Null(elite.ActiveTelegraph);
}
[Fact]
public async Task GetNpcRuntimeSnapshot_ShouldShowTelegraph_AfterAttackCooldownElapses()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeNpcMeleeAsync(client);
var castResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
PulseCastRequest());
castResponse.EnsureSuccessStatusCode();
var initResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
initResponse.EnsureSuccessStatusCode();
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3));
// Act
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
Assert.NotNull(body);
var melee = body!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal("telegraph_windup", melee.State);
Assert.NotNull(melee.ActiveTelegraph);
Assert.Equal("melee_pressure", melee.ActiveTelegraph!.ArchetypeKind);
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, melee.ActiveTelegraph.NpcInstanceId);
Assert.InRange(melee.ActiveTelegraph.WindupRemainingSeconds, 1.49, 1.51);
}
[Fact]
public async Task GetNpcRuntimeSnapshot_ShouldDecreaseWindupRemaining_AcrossPolls()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeNpcMeleeAsync(client);
var castResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
PulseCastRequest());
castResponse.EnsureSuccessStatusCode();
var initResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
initResponse.EnsureSuccessStatusCode();
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3));
var firstResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
factory.FakeClock.Advance(TimeSpan.FromSeconds(1));
// Act
var secondResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
// Assert
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
var firstBody = await firstResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
var secondBody = await secondResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
Assert.NotNull(firstBody);
Assert.NotNull(secondBody);
var firstMelee = firstBody!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
var secondMelee = secondBody!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.NotNull(firstMelee.ActiveTelegraph);
Assert.NotNull(secondMelee.ActiveTelegraph);
Assert.True(secondMelee.ActiveTelegraph!.WindupRemainingSeconds < firstMelee.ActiveTelegraph!.WindupRemainingSeconds);
Assert.InRange(secondMelee.ActiveTelegraph.WindupRemainingSeconds, 0.49, 0.51);
}
}

View File

@ -1,4 +1,6 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Combat;
@ -9,7 +11,13 @@ public static class CombatTargetFixtureApi
{
app.MapPost(
"/game/__dev/combat-targets-fixture",
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) =>
(
CombatTargetFixtureRequest? body,
ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore,
INpcRuntimeStateStore runtimeStore,
IPlayerCombatHealthStore playerHealthStore,
IOptions<GamePositionOptions> gameOptions) =>
{
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
{
@ -27,6 +35,12 @@ public static class CombatTargetFixtureApi
AggroOperations.ClearAllPrototypeHolders(threatStore);
NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore);
var devPlayerId = gameOptions.Value.DevPlayerId;
if (!playerHealthStore.TryResetToFull(devPlayerId, out _))
{
return Results.NotFound();
}
return Results.Json(
new CombatTargetFixtureResponse
{

View File

@ -0,0 +1,25 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Session in-memory player combat HP for incoming NPC damage (NEO-95).
/// No Postgres persistence in Epic 5 Slice 2.
/// </summary>
public interface IPlayerCombatHealthStore
{
/// <summary>
/// Reads the current snapshot for a player. Lazy-initializes HP to
/// <see cref="PlayerCombatHealthDefaults.MaxHp"/> on first access.
/// </summary>
bool TryGet(string? playerId, out PlayerCombatHealthSnapshot snapshot);
/// <summary>
/// Applies non-negative NPC attack damage. Lazy-initializes on first access.
/// Floors <see cref="PlayerCombatHealthSnapshot.CurrentHp"/> at zero.
/// </summary>
bool TryApplyDamage(string? playerId, int damage, out PlayerCombatHealthSnapshot snapshot);
/// <summary>
/// Restores a player to full HP. Creates the row when absent (dev fixture + tests).
/// </summary>
bool TryResetToFull(string? playerId, out PlayerCombatHealthSnapshot snapshot);
}

View File

@ -0,0 +1,91 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Thread-safe in-memory session player combat HP; empty at startup — lazy rows only (NEO-95).</summary>
public sealed class InMemoryPlayerCombatHealthStore : IPlayerCombatHealthStore
{
private readonly ConcurrentDictionary<string, int> currentHpById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryGet(string? playerId, out PlayerCombatHealthSnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0)
{
snapshot = default;
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
EnsureRowInitialized(key);
snapshot = CreateSnapshot(key, currentHpById[key]);
return true;
}
}
/// <inheritdoc />
public bool TryApplyDamage(string? playerId, int damage, out PlayerCombatHealthSnapshot snapshot)
{
snapshot = default;
if (damage < 0)
{
return false;
}
var key = NormalizePlayerId(playerId);
if (key.Length == 0)
{
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
EnsureRowInitialized(key);
var current = currentHpById[key];
var next = Math.Max(0, current - damage);
currentHpById[key] = next;
snapshot = CreateSnapshot(key, next);
return true;
}
}
/// <inheritdoc />
public bool TryResetToFull(string? playerId, out PlayerCombatHealthSnapshot snapshot)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0)
{
snapshot = default;
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
currentHpById[key] = PlayerCombatHealthDefaults.MaxHp;
snapshot = CreateSnapshot(key, PlayerCombatHealthDefaults.MaxHp);
return true;
}
}
private void EnsureRowInitialized(string normalizedId)
{
if (!currentHpById.ContainsKey(normalizedId))
{
currentHpById[normalizedId] = PlayerCombatHealthDefaults.MaxHp;
}
}
private static PlayerCombatHealthSnapshot CreateSnapshot(string playerId, int currentHp) =>
new(
playerId,
PlayerCombatHealthDefaults.MaxHp,
currentHp,
currentHp <= 0);
private static string NormalizePlayerId(string? playerId) =>
playerId?.Trim().ToLowerInvariant() ?? string.Empty;
}

View File

@ -0,0 +1,44 @@
using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95).</summary>
public static class NpcAttackOperations
{
/// <summary>
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when
/// the threat row still names that holder for <paramref name="npcInstanceId"/>.
/// </summary>
public static bool TryResolveTelegraphComplete(
string npcInstanceId,
string holderPlayerId,
int attackDamage,
IPlayerCombatHealthStore playerHealthStore,
IThreatStateStore threatStore)
{
if (attackDamage <= 0)
{
return true;
}
if (string.IsNullOrWhiteSpace(holderPlayerId) ||
!threatStore.TryGet(npcInstanceId, out var threat) ||
string.IsNullOrEmpty(threat.AggroHolderPlayerId) ||
!string.Equals(threat.AggroHolderPlayerId, holderPlayerId, StringComparison.Ordinal))
{
return false;
}
if (!playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out var snapshot))
{
return false;
}
if (snapshot.Defeated)
{
// TODO(E9.M1): player_death
}
return true;
}
}

View File

@ -0,0 +1,40 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Maps <c>GET /game/players/{id}/combat-health</c> (NEO-95).</summary>
public static class PlayerCombatHealthApi
{
public static WebApplication MapPlayerCombatHealthApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/combat-health",
(string id, IPositionStateStore positions, IPlayerCombatHealthStore playerHealthStore) =>
{
if (!positions.TryGetPosition(id, out _))
{
return Results.NotFound();
}
if (!playerHealthStore.TryGet(id, out var snapshot))
{
return Results.NotFound();
}
return Results.Json(BuildResponse(id, snapshot));
});
return app;
}
internal static PlayerCombatHealthResponse BuildResponse(
string playerId,
in PlayerCombatHealthSnapshot snapshot) =>
new()
{
PlayerId = playerId,
MaxHp = snapshot.MaxHp,
CurrentHp = snapshot.CurrentHp,
Defeated = snapshot.Defeated,
};
}

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Prototype session player combat HP defaults (E5M2-09 / NEO-95).</summary>
public static class PlayerCombatHealthDefaults
{
/// <summary>Lazy-init max HP for session player combat rows.</summary>
public const int MaxHp = 100;
}

View File

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>JSON body for <c>GET /game/players/{id}/combat-health</c> (NEO-95).</summary>
public sealed class PlayerCombatHealthResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
[JsonPropertyName("maxHp")]
public required int MaxHp { get; init; }
[JsonPropertyName("currentHp")]
public required int CurrentHp { get; init; }
[JsonPropertyName("defeated")]
public required bool Defeated { get; init; }
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Registers session player combat health store (NEO-95).</summary>
public static class PlayerCombatHealthServiceCollectionExtensions
{
/// <summary>Registers <see cref="IPlayerCombatHealthStore"/> as an in-memory singleton.</summary>
public static IServiceCollection AddPlayerCombatHealthStore(this IServiceCollection services)
{
services.AddSingleton<IPlayerCombatHealthStore, InMemoryPlayerCombatHealthStore>();
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Authoritative HP snapshot for a session player (NEO-95).</summary>
/// <param name="PlayerId">Normalized lowercase player id.</param>
/// <param name="MaxHp">Prototype session max HP.</param>
/// <param name="CurrentHp">Current HP after NPC damage; floored at zero.</param>
/// <param name="Defeated"><c>true</c> when <paramref name="CurrentHp"/> is zero.</param>
public readonly record struct PlayerCombatHealthSnapshot(
string PlayerId,
int MaxHp,
int CurrentHp,
bool Defeated);

View File

@ -1,3 +1,5 @@
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary>
@ -14,6 +16,7 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
{
if (maxDeltaSeconds <= 0)
@ -32,7 +35,8 @@ public static class NpcRuntimeOperations
nowUtc,
runtimeStore,
threatStore,
behaviorRegistry);
behaviorRegistry,
playerHealthStore);
}
runtimeStore.LastAdvancedUtc = nowUtc;
@ -50,7 +54,8 @@ public static class NpcRuntimeOperations
nowUtc,
runtimeStore,
threatStore,
behaviorRegistry);
behaviorRegistry,
playerHealthStore);
}
return;
@ -68,7 +73,8 @@ public static class NpcRuntimeOperations
windowEnd,
runtimeStore,
threatStore,
behaviorRegistry);
behaviorRegistry,
playerHealthStore);
}
runtimeStore.LastAdvancedUtc = windowEnd;
@ -84,7 +90,8 @@ public static class NpcRuntimeOperations
DateTimeOffset windowEnd,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry)
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore)
{
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
@ -102,7 +109,7 @@ public static class NpcRuntimeOperations
return;
}
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
if (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId))
{
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
{
@ -130,7 +137,7 @@ public static class NpcRuntimeOperations
var cursor = windowStart;
while (cursor < windowEnd)
{
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
if (!TryHasAggroHolder(npcInstanceId, threatStore, out aggroHolderPlayerId))
{
WriteIdle(runtimeStore, npcInstanceId);
return;
@ -176,24 +183,37 @@ public static class NpcRuntimeOperations
}
cursor = phaseEnd;
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
playerHealthStore,
threatStore);
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage)
// telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover
break;
}
case NpcBehaviorState.AttackExecute:
// Unreachable in normal windup→recover flow (NEO-93); defensive if execute becomes a visible phase.
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
playerHealthStore,
threatStore);
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here
// telemetry: npc_state_transition (NEO-96)
break;
case NpcBehaviorState.Recover:

View File

@ -0,0 +1,54 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>JSON body for <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary>
public sealed class NpcRuntimeSnapshotResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("serverTimeUtc")]
public DateTimeOffset ServerTimeUtc { get; init; }
/// <summary>Prototype NPC runtime rows ordered by ascending <c>npcInstanceId</c>.</summary>
[JsonPropertyName("npcInstances")]
public required IReadOnlyList<NpcInstanceRuntimeJson> NpcInstances { get; init; }
}
/// <summary>One row in the NPC runtime snapshot projection.</summary>
public sealed class NpcInstanceRuntimeJson
{
[JsonPropertyName("npcInstanceId")]
public required string NpcInstanceId { get; init; }
[JsonPropertyName("behaviorDefId")]
public required string BehaviorDefId { get; init; }
[JsonPropertyName("state")]
public required string State { get; init; }
[JsonPropertyName("aggroHolderPlayerId")]
public string? AggroHolderPlayerId { get; init; }
[JsonPropertyName("activeTelegraph")]
public ActiveTelegraphJson? ActiveTelegraph { get; init; }
}
/// <summary>Active telegraph nested on an NPC row during <c>telegraph_windup</c>.</summary>
public sealed class ActiveTelegraphJson
{
[JsonPropertyName("telegraphId")]
public required string TelegraphId { get; init; }
[JsonPropertyName("npcInstanceId")]
public required string NpcInstanceId { get; init; }
[JsonPropertyName("windupRemainingSeconds")]
public required double WindupRemainingSeconds { get; init; }
[JsonPropertyName("archetypeKind")]
public required string ArchetypeKind { get; init; }
}

View File

@ -0,0 +1,98 @@
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Maps <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary>
public static class NpcRuntimeSnapshotWorldApi
{
public static WebApplication MapNpcRuntimeSnapshotWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/npc-runtime-snapshot",
(
TimeProvider clock,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore) =>
{
var now = clock.GetUtcNow();
NpcRuntimeOperations.AdvanceAll(
now,
runtimeStore,
threatStore,
behaviorRegistry,
playerHealthStore);
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
});
return app;
}
internal static NpcRuntimeSnapshotResponse BuildSnapshot(
DateTimeOffset nowUtc,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry)
{
var ids = PrototypeNpcRegistry.GetInstanceIdsInOrder();
var instances = new List<NpcInstanceRuntimeJson>(ids.Count);
foreach (var npcInstanceId in ids)
{
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry))
{
throw new InvalidOperationException(
$"Prototype NPC instance '{npcInstanceId}' is missing from {nameof(PrototypeNpcRegistry)}.");
}
if (!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
{
throw new InvalidOperationException(
$"Behavior definition '{entry.BehaviorDefId}' for NPC '{npcInstanceId}' is missing from {nameof(INpcBehaviorDefinitionRegistry)}.");
}
if (!runtimeStore.TryGet(npcInstanceId, out var runtime))
{
throw new InvalidOperationException(
$"Prototype NPC runtime row '{npcInstanceId}' is registered but missing from {nameof(INpcRuntimeStateStore)}.");
}
string? aggroHolderPlayerId = null;
if (threatStore.TryGet(npcInstanceId, out var threat))
{
aggroHolderPlayerId = threat.AggroHolderPlayerId;
}
ActiveTelegraphJson? activeTelegraph = null;
if (runtime.BehaviorState == NpcBehaviorState.TelegraphWindup &&
runtime.ActiveTelegraph is { } telegraph)
{
var elapsedSeconds = (nowUtc - telegraph.WindupStartedUtc).TotalSeconds;
var remainingSeconds = Math.Max(0, behavior.TelegraphWindupSeconds - elapsedSeconds);
activeTelegraph = new ActiveTelegraphJson
{
TelegraphId = telegraph.TelegraphId,
NpcInstanceId = npcInstanceId,
WindupRemainingSeconds = remainingSeconds,
ArchetypeKind = behavior.ArchetypeKind,
};
}
instances.Add(
new NpcInstanceRuntimeJson
{
NpcInstanceId = npcInstanceId,
BehaviorDefId = entry.BehaviorDefId,
State = NpcBehaviorStateWire.ToWireName(runtime.BehaviorState),
AggroHolderPlayerId = aggroHolderPlayerId,
ActiveTelegraph = activeTelegraph,
});
}
return new NpcRuntimeSnapshotResponse
{
ServerTimeUtc = nowUtc,
NpcInstances = instances,
};
}
}

View File

@ -27,6 +27,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddPlayerCombatHealthStore();
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();
@ -58,6 +59,8 @@ app.MapRecipeDefinitionsWorldApi();
app.MapAbilityDefinitionsWorldApi();
app.MapNpcBehaviorDefinitionsWorldApi();
app.MapCombatTargetsWorldApi();
app.MapNpcRuntimeSnapshotWorldApi();
app.MapPlayerCombatHealthApi();
app.MapResourceNodeDefinitionsWorldApi();
app.MapPlayerInventoryApi();
app.MapPlayerCraftApi();

View File

@ -152,7 +152,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**), and resets NPC runtime behavior rows to **`idle`** (**NEO-93**). **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**), and restores dev player combat HP to full (**NEO-95**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
## Threat / aggro state (NEO-92)
@ -163,7 +163,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In
| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. |
| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. |
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
| **HTTP read** | Not exposed yet — **`GET /game/world/npc-runtime-snapshot`** lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). |
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md).
@ -176,18 +176,55 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** +
| **`idle`** | No aggro holder; no telegraph. |
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. |
| **`attack_execute`** | Instant stub in NEO-93 (player damage lands in [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). |
| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies catalog **`attackDamage`** to aggro holder ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). |
| **`recover`** | Post-attack cooldown wait before next windup. |
| Rule | Behavior |
|------|----------|
| **Holder sync** | Empty holder → **`idle`**; holder while **`idle`** → **`aggro`**. No proximity auto-aggro (first-hit holder from NEO-92 only). |
| **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) wires this to **`GET /game/world/npc-runtime-snapshot`**. |
| **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; invoked from **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
| **Fixture reset** | Dev combat-target fixture clears runtime rows alongside HP + threat. |
| **HTTP read** | Not exposed yet — snapshot GET lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). |
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — per-NPC **`state`**, optional nested **`activeTelegraph`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.md).
## NPC runtime snapshot (NEO-94)
**`GET /game/world/npc-runtime-snapshot`** returns a versioned JSON body (`schemaVersion` **1**, **`serverTimeUtc`**, **`npcInstances`**) backed by **`INpcRuntimeStateStore`** + **`IThreatStateStore`** after **`NpcRuntimeOperations.AdvanceAll`** (lazy tick, **5.0 s** delta cap). Each row includes **`npcInstanceId`**, **`behaviorDefId`**, **`state`** (stable snake_case), **`aggroHolderPlayerId`**, and optional nested **`activeTelegraph`** during **`telegraph_windup`**. All three **`PrototypeNpcRegistry`** instances are always returned in ascending **`npcInstanceId`** order. Plan: [NEO-94 implementation plan](../../docs/plans/NEO-94-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
| Field | Meaning |
|-------|---------|
| **`npcInstanceId`** | Lowercase prototype NPC instance id. |
| **`behaviorDefId`** | Frozen behavior catalog id bound to the instance. |
| **`state`** | **`idle`**, **`aggro`**, **`telegraph_windup`**, **`attack_execute`**, or **`recover`**. |
| **`aggroHolderPlayerId`** | Lowercase player id from **`IThreatStateStore`**, or **`null`**. |
| **`activeTelegraph`** | Non-null during **`telegraph_windup`**: **`telegraphId`**, **`npcInstanceId`**, **`windupRemainingSeconds`**, **`archetypeKind`**. |
```bash
curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot"
```
**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, catalog **`attackDamage`** applies to the aggro holder via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
## Session player combat HP (NEO-95)
Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPlayerCombatHealthStore`** + **`InMemoryPlayerCombatHealthStore`**. Rows are keyed by lowercase player id; lazy-init **`currentHp`** / **`maxHp`** **100** on first access. No Postgres in Slice 2.
| Rule | Behavior |
|------|----------|
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. |
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes and aggro holder still matches. |
| **Holder re-check** | No damage when holder cleared during windup (leash clear). |
| **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |
**`GET /game/players/{id}/combat-health`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`maxHp`**, **`currentHp`**, **`defeated`**) backed by **`IPlayerCombatHealthStore`**. **404** when the player has no position row (same gate as cooldown snapshot). Plan: [NEO-95 implementation plan](../../docs/plans/NEO-95-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-health/`.
```bash
curl -sS -i "http://localhost:5253/game/players/dev-local-1/combat-health"
```
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) polls this GET alongside **`npc-runtime-snapshot`** for player HP + telegraph HUD.
## Combat engine (NEO-81)
**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept.
@ -208,7 +245,8 @@ Comment-only hook sites in **`CombatOperations.TryResolve`** for future E9.M1 ca
- **`ability_used`** — on every successful resolve (zero-damage abilities included).
- **`enemy_defeat`** — only when lethal damage drives **`after.Defeated`** to **`true`** (re-hit on defeated targets denies at **`target_defeated`** before damage).
- **Reserved (comments only):** **`encounter_start`** (E5.M3 encounters), **`player_death`** (player HP deferred).
- **Reserved (comments only):** **`encounter_start`** (E5.M3 encounters).
- **`player_death`** — only when lethal NPC damage drives player **`currentHp`** to **0** (comment hook in NEO-95; full event deferred).
E1.M4 cast funnel events **`ability_cast_requested`** / **`ability_cast_denied`** remain in **`AbilityCastApi`** ([NEO-30](#ability-cast-neo-31-neo-82)). E9.M1 ingest should correlate route **`playerId`** with engine payload fields at accept. Plan: [NEO-84 implementation plan](../../docs/plans/NEO-84-implementation-plan.md).