# 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`** until elite past windup (real-time wait or sleep ≥ **7.5 s** against dev server). 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 may use **`sleep(8000)`** against dev server — document in `.bru` docs block (NEO-94 pattern). | **adopted** |