diff --git a/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru new file mode 100644 index 0000000..3e2667f --- /dev/null +++ b/bruno/neon-sprawl-server/combat-health/Get combat health after elite attack.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/combat-health/Get player combat health.bru b/bruno/neon-sprawl-server/combat-health/Get player combat health.bru new file mode 100644 index 0000000..9ca6dcd --- /dev/null +++ b/bruno/neon-sprawl-server/combat-health/Get player combat health.bru @@ -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); + }); +} diff --git a/bruno/neon-sprawl-server/combat-health/folder.bru b/bruno/neon-sprawl-server/combat-health/folder.bru new file mode 100644 index 0000000..6ab1ba3 --- /dev/null +++ b/bruno/neon-sprawl-server/combat-health/folder.bru @@ -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. +} diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 03510cb..fc58734 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-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) | +| **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 @@ -89,6 +89,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **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/`. ## Risks and telemetry diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5208a27..73ea7b7 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). **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 | +| 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/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model ([NEO-95](../../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/`. **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), [NEO-95](../../plans/NEO-95-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 4428249..289c6ec 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -297,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. diff --git a/docs/plans/NEO-95-implementation-plan.md b/docs/plans/NEO-95-implementation-plan.md new file mode 100644 index 0000000..dc6997f --- /dev/null +++ b/docs/plans/NEO-95-implementation-plan.md @@ -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`** 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** | diff --git a/docs/reviews/2026-05-28-NEO-95.md b/docs/reviews/2026-05-28-NEO-95.md new file mode 100644 index 0000000..84f8837 --- /dev/null +++ b/docs/reviews/2026-05-28-NEO-95.md @@ -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-87–NEO-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`**. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index b609f3a..584fbe9 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -22,7 +22,9 @@ public sealed class CombatTargetFixtureApiTests var store = factory.Services.GetRequiredService(); var threatStore = factory.Services.GetRequiredService(); var runtimeStore = factory.Services.GetRequiredService(); + var playerHealthStore = factory.Services.GetRequiredService(); _ = 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] diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs new file mode 100644 index 0000000..f8c2084 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs @@ -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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs new file mode 100644 index 0000000..c38c06b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthApiTests.cs @@ -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(); + 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(); + var healthBody = await healthResponse.Content.ReadFromJsonAsync(); + 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(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs new file mode 100644 index 0000000..023785c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/PlayerCombatHealthStoreTests.cs @@ -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(); + // Assert + Assert.NotNull(store); + Assert.True(store.TryGet("dev-local-1", out var snapshot)); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 203b098..572eabb 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -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() { diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 2d27d96..7befa5b 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -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 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 { diff --git a/server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs new file mode 100644 index 0000000..a7da503 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/IPlayerCombatHealthStore.cs @@ -0,0 +1,25 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Session in-memory player combat HP for incoming NPC damage (NEO-95). +/// No Postgres persistence in Epic 5 Slice 2. +/// +public interface IPlayerCombatHealthStore +{ + /// + /// Reads the current snapshot for a player. Lazy-initializes HP to + /// on first access. + /// + bool TryGet(string? playerId, out PlayerCombatHealthSnapshot snapshot); + + /// + /// Applies non-negative NPC attack damage. Lazy-initializes on first access. + /// Floors at zero. + /// + bool TryApplyDamage(string? playerId, int damage, out PlayerCombatHealthSnapshot snapshot); + + /// + /// Restores a player to full HP. Creates the row when absent (dev fixture + tests). + /// + bool TryResetToFull(string? playerId, out PlayerCombatHealthSnapshot snapshot); +} diff --git a/server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs new file mode 100644 index 0000000..fe5f0db --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/InMemoryPlayerCombatHealthStore.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Combat; + +/// Thread-safe in-memory session player combat HP; empty at startup — lazy rows only (NEO-95). +public sealed class InMemoryPlayerCombatHealthStore : IPlayerCombatHealthStore +{ + private readonly ConcurrentDictionary currentHpById = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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; +} diff --git a/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs new file mode 100644 index 0000000..c83cba8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs @@ -0,0 +1,44 @@ +using NeonSprawl.Server.Game.Npc; + +namespace NeonSprawl.Server.Game.Combat; + +/// Deterministic NPC telegraph-complete damage resolve (NEO-95). +public static class NpcAttackOperations +{ + /// + /// Applies catalog to when + /// the threat row still names that holder for . + /// + 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs new file mode 100644 index 0000000..5a98d3c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthApi.cs @@ -0,0 +1,40 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Combat; + +/// Maps GET /game/players/{id}/combat-health (NEO-95). +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, + }; +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs new file mode 100644 index 0000000..abcdd7f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDefaults.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Prototype session player combat HP defaults (E5M2-09 / NEO-95). +public static class PlayerCombatHealthDefaults +{ + /// Lazy-init max HP for session player combat rows. + public const int MaxHp = 100; +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs new file mode 100644 index 0000000..95cc967 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthDtos.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Combat; + +/// JSON body for GET /game/players/{id}/combat-health (NEO-95). +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; } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs new file mode 100644 index 0000000..1aa5ce3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Registers session player combat health store (NEO-95). +public static class PlayerCombatHealthServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddPlayerCombatHealthStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs new file mode 100644 index 0000000..5b32379 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PlayerCombatHealthSnapshot.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Authoritative HP snapshot for a session player (NEO-95). +/// Normalized lowercase player id. +/// Prototype session max HP. +/// Current HP after NPC damage; floored at zero. +/// true when is zero. +public readonly record struct PlayerCombatHealthSnapshot( + string PlayerId, + int MaxHp, + int CurrentHp, + bool Defeated); diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index ceaad3f..17e40e9 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Combat; + namespace NeonSprawl.Server.Game.Npc; /// Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93). @@ -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: diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs index 7ef419c..c565a94 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -1,3 +1,5 @@ +using NeonSprawl.Server.Game.Combat; + namespace NeonSprawl.Server.Game.Npc; /// Maps GET /game/world/npc-runtime-snapshot (NEO-94). @@ -11,10 +13,16 @@ public static class NpcRuntimeSnapshotWorldApi TimeProvider clock, INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, - INpcBehaviorDefinitionRegistry behaviorRegistry) => + INpcBehaviorDefinitionRegistry behaviorRegistry, + IPlayerCombatHealthStore playerHealthStore) => { var now = clock.GetUtcNow(); - NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry); + NpcRuntimeOperations.AdvanceAll( + now, + runtimeStore, + threatStore, + behaviorRegistry, + playerHealthStore); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry)); }); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index f1b86ba..cf5d5d7 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); @@ -59,6 +60,7 @@ app.MapAbilityDefinitionsWorldApi(); app.MapNpcBehaviorDefinitionsWorldApi(); app.MapCombatTargetsWorldApi(); app.MapNpcRuntimeSnapshotWorldApi(); +app.MapPlayerCombatHealthApi(); app.MapResourceNodeDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapPlayerCraftApi(); diff --git a/server/README.md b/server/README.md index 24bc59d..fc410fd 100644 --- a/server/README.md +++ b/server/README.md @@ -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) @@ -176,7 +176,7 @@ 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 | @@ -204,7 +204,26 @@ Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.m 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). +**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) @@ -226,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).