diff --git a/bruno/neon-sprawl-server/npc-runtime-snapshot/Get npc runtime snapshot.bru b/bruno/neon-sprawl-server/npc-runtime-snapshot/Get npc runtime snapshot.bru new file mode 100644 index 0000000..76d4ea5 --- /dev/null +++ b/bruno/neon-sprawl-server/npc-runtime-snapshot/Get npc runtime snapshot.bru @@ -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", + ); + }); +} diff --git a/bruno/neon-sprawl-server/npc-runtime-snapshot/Get snapshot after cast telegraph.bru b/bruno/neon-sprawl-server/npc-runtime-snapshot/Get snapshot after cast telegraph.bru new file mode 100644 index 0000000..7fd13de --- /dev/null +++ b/bruno/neon-sprawl-server/npc-runtime-snapshot/Get snapshot after cast telegraph.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/npc-runtime-snapshot/folder.bru b/bruno/neon-sprawl-server/npc-runtime-snapshot/folder.bru new file mode 100644 index 0000000..1fe6b23 --- /dev/null +++ b/bruno/neon-sprawl-server/npc-runtime-snapshot/folder.bru @@ -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. +} diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 225f05b..03510cb 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -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-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,11 @@ 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/`. **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/`. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index c6e8f5b..5208a27 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -57,7 +57,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | -| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../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/`. **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 5a36305..4428249 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -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. diff --git a/docs/plans/NEO-94-implementation-plan.md b/docs/plans/NEO-94-implementation-plan.md new file mode 100644 index 0000000..908897d --- /dev/null +++ b/docs/plans/NEO-94-implementation-plan.md @@ -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) | diff --git a/docs/reviews/2026-05-28-NEO-94.md b/docs/reviews/2026-05-28-NEO-94.md new file mode 100644 index 0000000..2828276 --- /dev/null +++ b/docs/reviews/2026-05-28-NEO-94.md @@ -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-90–NEO-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(); + ```~~ **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. diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeSnapshotWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeSnapshotWorldApiTests.cs new file mode 100644 index 0000000..259133b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeSnapshotWorldApiTests.cs @@ -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(); + 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(); + 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(); + 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(); + var secondBody = await secondResponse.Content.ReadFromJsonAsync(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotDtos.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotDtos.cs new file mode 100644 index 0000000..be81cea --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotDtos.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Npc; + +/// JSON body for GET /game/world/npc-runtime-snapshot (NEO-94). +public sealed class NpcRuntimeSnapshotResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("serverTimeUtc")] + public DateTimeOffset ServerTimeUtc { get; init; } + + /// Prototype NPC runtime rows ordered by ascending npcInstanceId. + [JsonPropertyName("npcInstances")] + public required IReadOnlyList NpcInstances { get; init; } +} + +/// One row in the NPC runtime snapshot projection. +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; } +} + +/// Active telegraph nested on an NPC row during telegraph_windup. +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; } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs new file mode 100644 index 0000000..7ef419c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -0,0 +1,90 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Maps GET /game/world/npc-runtime-snapshot (NEO-94). +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) => + { + var now = clock.GetUtcNow(); + NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry); + 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(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, + }; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 543d87e..f1b86ba 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -58,6 +58,7 @@ app.MapRecipeDefinitionsWorldApi(); app.MapAbilityDefinitionsWorldApi(); app.MapNpcBehaviorDefinitionsWorldApi(); app.MapCombatTargetsWorldApi(); +app.MapNpcRuntimeSnapshotWorldApi(); app.MapResourceNodeDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); diff --git a/server/README.md b/server/README.md index a76fb6b..24bc59d 100644 --- a/server/README.md +++ b/server/README.md @@ -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). @@ -182,12 +182,30 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** + | 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). + ## 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.