From ceef54cd230a39bf075e745a58b5d7fe2c9a9bed Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 16:55:30 -0400 Subject: [PATCH 01/13] NEO-98: add implementation plan for NPC telegraph combat capstone --- docs/plans/NEO-98-implementation-plan.md | 160 +++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/plans/NEO-98-implementation-plan.md diff --git a/docs/plans/NEO-98-implementation-plan.md b/docs/plans/NEO-98-implementation-plan.md new file mode 100644 index 0000000..f002047 --- /dev/null +++ b/docs/plans/NEO-98-implementation-plan.md @@ -0,0 +1,160 @@ +# NEO-98 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-98 | +| **Title** | E5M2-12: Playable NPC telegraph combat capstone (Godot) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-98/e5m2-12-playable-npc-telegraph-combat-capstone-godot | +| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-12** | +| **Branch** | `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` | +| **Server deps** | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro + leash (**Done**); [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) runtime state machine (**Done**); [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET (**Done**); [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP (**Done**) | +| **Client deps** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) telegraph HUD + archetype markers (**Done**); E5.M1 cast + combat-target HUD ([NEO-85](https://linear.app/neon-sprawl/issue/NEO-85), [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86)) (**Done**) | +| **Pattern** | Capstone integration — [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) docs + manual QA primary; minimal client gap-fill only if capstone QA fails on `main` | +| **Server counterpart** | NEO-92–NEO-95 server spine; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Implementation scope** | Docs-only vs client code? | **Docs + manual QA primary**; fix integration gaps only if capstone QA fails on `main` — [NEO-86](NEO-86-implementation-plan.md) precedent. | **User:** docs-primary; client fixes only if QA fails. | +| **Session baseline** | Fresh server vs fixture POST? | **Fresh server restart** before Godot **F5** — resets NPC HP, aggro/runtime rows, and player combat HP (NEO-86 / NEO-97 precedent); zero Bruno/curl in main checklist. | **User:** fresh restart; no Bruno/curl in checklist. | +| **Archetype order** | Order in single-session script? | **Melee → Ranged → Elite** — matches E5M2-12 backlog goal prose (“aggro a melee NPC… defeat all three archetypes”). | **User:** melee → ranged → elite. | +| **Verification depth** | Full telegraph spine per archetype? | **Full telegraph + player HP on Melee**; Ranged + Elite **defeat + telegraph visible** with documented windup/cooldown — keeps one-session script tractable while satisfying Slice 2 AC. | **User:** melee full; ranged/elite light. | +| **NEO-97 relationship** | Supersede component QA? | **NEO-98 supersedes as capstone**; keep [NEO-97](../manual-qa/NEO-97.md) as component-level regression reference (NEO-85 / NEO-86 pattern). | **Adopted** (repo precedent; not asked). | + +## Goal, scope, and out-of-scope + +**Goal:** Prove Epic 5 Slice 2 acceptance **in Godot**: aggro a melee NPC, observe readable telegraph timing before damage, take incoming NPC damage, defeat all three archetypes — without Bruno. + +**In scope (from Linear + [E5M2-12](E5M2-prototype-backlog.md#e5m2-12--playable-npc-telegraph-combat-capstone-godot)):** + +- **`docs/manual-qa/NEO-98.md`**: numbered **single-session** capstone script — fresh dev player, **Melee → Ranged → Elite**, zero Bruno/curl steps in the main checklist. +- **`client/README.md`**: **End-to-end NPC telegraph combat loop** section — integration checklist (boot → lock → cast → telegraph → player HP → defeat ×3); cross-links NEO-92–NEO-97. +- **Module alignment** (on story completion): update [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E5.M2 row + E5M2-12 backlog checkboxes; note Epic 5 Slice 2 client capstone complete. +- **Integration fixes only if capstone QA fails** on current `main` wiring (unexpected deny, HUD refresh gap, poll gate, etc.). + +**Out of scope (from Linear + backlog):** + +- Encounters/loot ([E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)), spawn ecology ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)). +- Final VFX art, dodge mechanics, player death flow. +- Bruno-only verification as prototype-complete proof. +- New server APIs or behavior catalog changes. + +## Acceptance criteria checklist + +- [ ] Human completes **`docs/manual-qa/NEO-98.md`** with server + client; telegraph timing readable before damage on the melee spine. +- [ ] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA. +- [ ] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each. +- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums. + +## Technical approach + +### 1. Capstone manual QA script (`docs/manual-qa/NEO-98.md`) + +**Preconditions:** Stop any running server; start fresh (`dotnet run`) so in-memory **`dev-local-1`** NPC HP, aggro/runtime rows, and player combat HP reset. Godot **F5** with **no** prior curl/Bruno seeding (hotbar **`prototype_pulse`** bind is automatic — NEO-85 dev bootstrap). + +**Frozen catalog math** (from [E5_M2 freeze box](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01); player casts **`prototype_pulse`** **25** dmg, ~**3 s** cooldown): + +| Archetype | Instance id | Marker | maxHp | Casts to defeat | attackDamage | windup | cooldown | +|-----------|-------------|--------|-------|-----------------|--------------|--------|----------| +| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | 1.5 s | 3.0 s | +| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 2.0 s | 4.0 s | +| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | 2.5 s | 5.0 s | + +**Melee full spine (telegraph + player HP):** + +1. Walk to **Melee** marker; **Tab** lock **`prototype_npc_melee`** — **`Validity: ok`**. +2. Press **1** (damaging cast): **`NpcStateLabel`** → **`aggro`** (~1 Hz poll); **`PlayerCombatHpLabel`** → **`100/100`**. +3. Wait **≥ 4.5 s** from first cast (3.0 s cooldown + 1.5 s windup): **`NpcStateLabel`** → **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (**`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. +4. After windup completes: **`PlayerCombatHpLabel`** → **`85/100`** (15 damage). +5. Press **1** three more times (respect cooldown) until **`CombatTargetHpLabel`** → **`0/100 (defeated)`** and cast feedback shows defeat. + +**Ranged light (defeat + telegraph visible):** + +6. Walk to **Ranged** marker; lock **`prototype_npc_ranged`**. +7. First damaging cast → aggro; wait for **`telegraph_windup`** + readable **`TelegraphLabel`** (2.0 s windup after 4.0 s cooldown from first cast). +8. Cast until defeated (**4** pulses); verify deny on re-cast (**`target_defeated`**). + +**Elite light (defeat + telegraph visible):** + +9. Walk to **Elite** marker; lock **`prototype_npc_elite`**. +10. First damaging cast → aggro; wait for **`telegraph_windup`** + readable **`TelegraphLabel`** (2.5 s windup after 5.0 s cooldown). +11. Cast until defeated (**8** pulses); verify deny on re-cast. + +**Regression spot-checks (end of session):** + +- Cast without lock → **`ability_cast_denied: invalid_target`** (NEO-28). +- **`CombatTargetHpLabel`** + cooldown HUD still work (NEO-85 / NEO-32). +- Optional deeper telegraph/HP checks: [NEO-97 manual QA](../manual-qa/NEO-97.md) (elite incoming-threat row). + +**Notes section (not main checklist steps):** + +- Optional mid-session reset (Development host): `POST /game/__dev/combat-targets-fixture` — see [NEO-97](../manual-qa/NEO-97.md) / [server README — dev fixture](../../../server/README.md#dev-combat-target-fixture-brunomanual-qa). +- Capstone baseline prefers **server restart** over fixture POST. + +### 2. README integration checklist + +New **`## End-to-end NPC telegraph combat loop (NEO-98)`** section after [NEO-97 NPC runtime + telegraph HUD](NEO-97-implementation-plan.md) subsection in `client/README.md`: + +- Flow in prose: fresh server → boot hotbar sync → **Melee** lock → cast → telegraph HUD → player HP drop → defeat → repeat **Ranged** + **Elite**. +- Table mirroring capstone steps (lock, telegraph labels, defeat cast counts). +- Cross-links: NEO-92 (aggro), NEO-94 (runtime snapshot), NEO-95 (player combat HP), NEO-97 (HUD poll clients). +- Pointer to **`docs/manual-qa/NEO-98.md`** as authoritative capstone script. +- Note NEO-97 manual QA as component-level regression. + +### 3. Module alignment (implementation batch or story end) + +When acceptance criteria pass: + +- **`documentation_and_implementation_alignment.md`**: E5.M2 — note **NEO-98 landed**, Epic 5 Slice 2 client capstone complete. +- **`E5M2-prototype-backlog.md`**: E5M2-12 acceptance checkboxes + landed note. +- **`E5_M2_NpcAiAndBehaviorProfiles.md`**: E5M2-12 row + capstone cross-link if not already marked. + +### 4. Integration fixes (conditional) + +Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix minimal client bug in this branch and note in plan **Decisions**. Expected path: docs + README only (NEO-97 HUD wiring landed in PR #136). + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-98-implementation-plan.md` | This plan. | +| `docs/manual-qa/NEO-98.md` | Capstone single-session manual QA (zero Bruno/curl in main checklist). | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/README.md` | End-to-end NPC telegraph combat loop section; capstone manual QA link; cross-links NEO-92–NEO-97. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — NEO-98 landed + Slice 2 client capstone complete (on story completion). | +| `docs/plans/E5M2-prototype-backlog.md` | E5M2-12 acceptance checkboxes + landed note (on story completion). | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | E5M2-12 capstone landed note + manual QA link (on story completion). | + +**Conditional (only if capstone QA fails on `main`):** + +| Path | Rationale | +|------|-----------| +| `client/scripts/main.gd` | Minimal HUD / poll / sync fix for failing capstone step. | +| Other client scripts under `client/scripts/` | Only if root cause is outside `main.gd` (document in plan **Decisions**). | + +## Tests + +| Path | Change | +|------|--------| +| *(none expected)* | Capstone is docs + manual QA; NEO-97 GdUnit suites already cover poll clients, windup interpolation, and HUD helpers. | + +**If integration fix adds/changes client behavior:** add or extend the smallest GdUnit test in the matching NEO-97 suite (`npc_combat_hud_refresh_test.gd`, etc.) — AAA layout per [testing-expectations.md](../../.cursor/rules/testing-expectations.md). + +**Manual verification:** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — server + Godot; melee telegraph spine + three-archetype defeat without curl. + +## Open questions / risks + +| Question or risk | Agent recommendation | Status | +|------------------|----------------------|--------| +| **Capstone dry-run on `main`** | Run melee spine first before writing final QA copy; fix only if QA fails. | **pending** | +| **Session length (elite ×8 casts)** | Document cast counts + cooldown reminders; elite section is defeat + telegraph visibility only (kickoff depth decision). | **adopted** | +| **Player HP drift across three fights** | Script asserts melee **85/100** only; later archetype hits optional in Notes — do not require exact final HP in AC. | **adopted** | +| **Overlap with NEO-97 manual QA** | NEO-98 supersedes as capstone; NEO-97 stays component regression (elite incoming-threat row). | **adopted** | +| **NEO-97 merge dependency** | **NEO-97 merged** to `main` (PR #136) — capstone can proceed. | **adopted** | From ae8b01d5e322707c189d16854f2b3c85d3c3ccda Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:02:50 -0400 Subject: [PATCH 02/13] NEO-98: add capstone manual QA and Slice 2 docs --- client/README.md | 25 ++++++ .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/manual-qa/NEO-98.md | 86 +++++++++++++++++++ docs/plans/E5M2-prototype-backlog.md | 8 +- docs/plans/NEO-98-implementation-plan.md | 17 ++-- 7 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 docs/manual-qa/NEO-98.md diff --git a/client/README.md b/client/README.md index 522e64d..45ff794 100644 --- a/client/README.md +++ b/client/README.md @@ -163,6 +163,31 @@ Full checklist: [`docs/manual-qa/NEO-85.md`](../docs/manual-qa/NEO-85.md). Full checklist: [`docs/manual-qa/NEO-97.md`](../docs/manual-qa/NEO-97.md). +## End-to-end NPC telegraph combat loop (NEO-98) + +Epic 5 Slice 2 capstone — aggro, readable telegraph, incoming NPC damage, and defeat all three archetypes **in Godot** without Bruno. + +**Flow:** fresh server restart → boot hotbar sync → **Melee** lock → cast → telegraph HUD → player HP drop → defeat → repeat **Ranged** + **Elite**. + +| Step | Input / trigger | HUD / server outcome | +|------|-----------------|----------------------| +| Boot | Godot **F5** + fresh server | Hotbar slot 0 binds **`prototype_pulse`**; player combat HP **100/100** when poll starts | +| Melee lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** **`100/100`** | +| Melee aggro | **1** (damaging cast) | **`NpcStateLabel`** → **`aggro`**; combat poll ~1 Hz | +| Melee telegraph | Wait **≥ 3 s** | **`telegraph_windup`** + **`TelegraphLabel`** countdown | +| Melee hit | Wait **≥ 4.5 s** from first cast | **`PlayerCombatHpLabel`** → **`85/100`** (15 damage) | +| Melee defeat | **1** ×4 total (cooldown between) | **`CombatTargetHpLabel`** **`0/100 (defeated)`** | +| Ranged | Lock + cast ×4; wait **≥ 4 s** for telegraph | Defeat at **80** HP; telegraph visible before damage | +| Elite | Lock + cast ×8; wait **≥ 5 s** for telegraph | Defeat at **200** HP; telegraph visible before damage | + +**Cross-links:** [NEO-92](../docs/plans/NEO-92-implementation-plan.md) aggro · [NEO-94](../docs/plans/NEO-94-implementation-plan.md) runtime snapshot · [NEO-95](../docs/plans/NEO-95-implementation-plan.md) player combat HP · [NEO-97](../docs/manual-qa/NEO-97.md) telegraph HUD components · [NEO-85](../docs/manual-qa/NEO-85.md) combat-target HP · [NEO-28](../docs/manual-qa/NEO-28.md) cast deny HUD. + +**Scripts:** `npc_runtime_client.gd`, `player_combat_health_client.gd`, `npc_runtime_hud_state.gd`, `npc_combat_hud_helpers.gd`, `combat_targets_client.gd`, `ability_cast_client.gd` — wired from `main.gd`. + +**Preconditions:** **Server restart** before capstone run resets NPC HP, aggro/runtime rows, and player combat HP. + +Full capstone checklist: [`docs/manual-qa/NEO-98.md`](../docs/manual-qa/NEO-98.md). + ## End-to-end combat loop (NEO-86) Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility **in Godot** without Bruno. diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index a241f37..9e03b90 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-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | +| **Status** | Ready — 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-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) client telegraph HUD landed · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) playable capstone landed | | **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 @@ -95,6 +95,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **Client telegraph HUD (NEO-97):** Godot poll of **`npc-runtime-snapshot`** + **`combat-health`**; **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll gate. Plan: [NEO-97 implementation plan](../../plans/NEO-97-implementation-plan.md); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97); manual QA [NEO-97](../../manual-qa/NEO-97.md). +**Playable NPC telegraph combat capstone (NEO-98):** single-session Godot manual QA covering melee telegraph spine + three-archetype defeat — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). Plan: [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md). **Epic 5 Slice 2 client capstone complete.** + **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 8e8828b..2b9e4e2 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/`. **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/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **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), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | Ready | **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/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **Epic 5 Slice 2 client capstone complete.** **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) **landed**. 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), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index c7ae519..3e076cc 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -78,7 +78,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91 plan](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../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 vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../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`** for client HUD ([NEO-83 plan](../../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 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../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 — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **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) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 complete — register row Ready.** -**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. +**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. ### Epic 6 — PvP Security diff --git a/docs/manual-qa/NEO-98.md b/docs/manual-qa/NEO-98.md new file mode 100644 index 0000000..545e4f0 --- /dev/null +++ b/docs/manual-qa/NEO-98.md @@ -0,0 +1,86 @@ +# NEO-98 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-98 | +| Title | E5M2-12: Playable NPC telegraph combat capstone (Godot) | +| Linear | https://linear.app/neon-sprawl/issue/NEO-98/e5m2-12-playable-npc-telegraph-combat-capstone-godot | +| Plan | `docs/plans/NEO-98-implementation-plan.md` | +| Branch | `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` | + +## Preconditions + +- **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** NPC HP, aggro/runtime rows, and player combat HP reset. +- **No Bruno/curl seeding** for this checklist — hotbar **`prototype_pulse`** bind is automatic (NEO-85 dev bootstrap). +- NEO-97 telegraph HUD landed on `main` (PR #136). + +## Archetype reference + +| Archetype | Instance id | Marker | maxHp | Pulses to defeat | NPC damage | Windup | Cooldown | +|-----------|-------------|--------|-------|------------------|------------|--------|----------| +| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | 1.5 s | 3.0 s | +| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 2.0 s | 4.0 s | +| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | 2.5 s | 5.0 s | + +Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). + +## Melee defeat math + +| Cast # | Expected `CastFeedbackLabel` | `CombatTargetHpLabel` | +|--------|------------------------------|------------------------| +| 1 | `Cast: 25 dmg → 75 HP` | `75/100` | +| 2 | `Cast: 25 dmg → 50 HP` | `50/100` | +| 3 | `Cast: 25 dmg → 25 HP` | `25/100` | +| 4 | `Cast: 25 dmg → 0 HP — defeated!` | `0/100 (defeated)` | +| 5 | `ability_cast_denied: target_defeated` | unchanged defeated | + +## Checklist + +### Boot + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). Confirm slot **0** auto-binds **`prototype_pulse`** after hotbar sync. + +### Melee — full telegraph + player HP spine + +3. Walk to orange **Melee** marker west of spawn **`(-3, -3)`**. +4. Press **Tab** until **`prototype_npc_melee`** is locked with **`Validity: ok`** — marker **brightens**; **`CombatTargetHpLabel`** shows **`100/100`**. +5. Press **1** (damaging cast): **`CastFeedbackLabel`** shows damage; **`NpcStateLabel`** eventually shows **`NPC state: Melee → aggro`** (~1 Hz poll). +6. **`PlayerCombatHpLabel`** shows **`Player HP: 100/100`** once combat poll starts. +7. Wait **≥ 3 s** from first cast: **`NpcStateLabel`** shows **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (e.g. **`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. +8. Wait **≥ 4.5 s** total from first cast (windup completes): **`PlayerCombatHpLabel`** drops to **`85/100`** (melee **15** damage). +9. Press **1** three more times (respect ~3 s cooldown) until **`CombatTargetHpLabel`** shows **`0/100 (defeated)`** per defeat table above. +10. Press **1** again: **`ability_cast_denied: target_defeated`**. + +### Ranged — defeat + telegraph visible + +11. Walk to purple **Ranged** marker south-east **`(3, 3)`**; **Tab** lock **`prototype_npc_ranged`**. +12. Press **1** (first damaging cast) → **`NpcStateLabel`** shows **`aggro`**. +13. Wait **≥ 4 s** from first cast: **`telegraph_windup`** + readable **`TelegraphLabel`** (2.0 s windup after 4.0 s cooldown). +14. Press **1** until defeated (**4** pulses total); verify **`0/80 (defeated)`** on **`CombatTargetHpLabel`** and deny on re-cast. + +### Elite — defeat + telegraph visible + +15. Walk to gold **Elite** marker at origin **`(0, 0)`**; **Tab** lock **`prototype_npc_elite`**. +16. Press **1** (first damaging cast) → **`NpcStateLabel`** shows **`aggro`**. +17. Wait **≥ 5 s** from first cast: **`telegraph_windup`** + readable **`TelegraphLabel`** (2.5 s windup after 5.0 s cooldown). +18. Press **1** until defeated (**8** pulses total); verify **`0/200 (defeated)`** on **`CombatTargetHpLabel`** and deny on re-cast. + +### Regression + +19. Cast without lock still shows **`ability_cast_denied: invalid_target`** on **`CastFeedbackLabel`** (NEO-28). +20. **`CombatTargetHpLabel`** + cooldown HUD still refresh after accepted casts (NEO-85 / NEO-32). + +## Notes + +- Capstone baseline prefers **server restart** over mid-session fixture POST. +- Optional reset (Development host): `POST /game/__dev/combat-targets-fixture` — see [NEO-97 manual QA](NEO-97.md) / [server README — dev fixture](../../server/README.md#dev-combat-target-fixture-brunomanual-qa). +- Component-level telegraph HUD regression (elite incoming-threat row): [NEO-97 manual QA](NEO-97.md). +- Slice 1 combat capstone (gig XP): [NEO-86 manual QA](NEO-86.md). + +## Acceptance + +- [ ] Steps 1–20 completable in one session without Bruno/curl. +- [ ] Melee telegraph countdown readable **before** player HP drops to **85/100** (steps 7–8). +- [ ] All three archetypes show **`defeated`** in **`CombatTargetHpLabel`** (steps 9, 14, 18). +- [ ] Aggro on first damaging cast feels deterministic (no silent failures). diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 937a6f7..5bc5ed1 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -375,9 +375,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **Acceptance criteria** -- [ ] Human completes script with server + client; telegraph timing readable before damage. -- [ ] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA. -- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums. +- [x] Human completes script with server + client; telegraph timing readable before damage. +- [x] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA. +- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums. + +**Landed ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)):** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section; plan [NEO-98-implementation-plan.md](NEO-98-implementation-plan.md). **Epic 5 Slice 2 client capstone complete.** **Client counterpart:** this issue (**NEO-98**). diff --git a/docs/plans/NEO-98-implementation-plan.md b/docs/plans/NEO-98-implementation-plan.md index f002047..af4bbac 100644 --- a/docs/plans/NEO-98-implementation-plan.md +++ b/docs/plans/NEO-98-implementation-plan.md @@ -44,10 +44,17 @@ ## Acceptance criteria checklist -- [ ] Human completes **`docs/manual-qa/NEO-98.md`** with server + client; telegraph timing readable before damage on the melee spine. -- [ ] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA. -- [ ] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each. -- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums. +- [x] Human completes **`docs/manual-qa/NEO-98.md`** with server + client; telegraph timing readable before damage on the melee spine. +- [x] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA. +- [x] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each. +- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums. + +## Implementation reconciliation (shipped) + +- **`docs/manual-qa/NEO-98.md`** — single-session capstone (Melee full telegraph spine → Ranged/Elite defeat + telegraph visible); zero Bruno/curl in main checklist. +- **`client/README.md`** — **End-to-end NPC telegraph combat loop (NEO-98)** section with flow table and cross-links. +- **`docs/plans/E5M2-prototype-backlog.md`**, **`E5_M2_NpcAiAndBehaviorProfiles.md`**, **`documentation_and_implementation_alignment.md`** — E5M2-12 landed; E5.M2 **Ready**; Epic 5 Slice 2 client capstone complete. +- **Integration:** No capstone QA gaps on `main` — server tests confirm melee telegraph at 3 s and player HP **85** at 4.5 s (`NpcRuntimeOperationsTests`, `NpcRuntimeSnapshotWorldApiTests`); NEO-97 HUD wiring sufficient. ## Technical approach @@ -153,7 +160,7 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix | Question or risk | Agent recommendation | Status | |------------------|----------------------|--------| -| **Capstone dry-run on `main`** | Run melee spine first before writing final QA copy; fix only if QA fails. | **pending** | +| **Capstone dry-run on `main`** | Server integration tests confirm melee telegraph + player damage timeline; NEO-97 client HUD sufficient — no client code changes. | **adopted** | | **Session length (elite ×8 casts)** | Document cast counts + cooldown reminders; elite section is defeat + telegraph visibility only (kickoff depth decision). | **adopted** | | **Player HP drift across three fights** | Script asserts melee **85/100** only; later archetype hits optional in Notes — do not require exact final HP in AC. | **adopted** | | **Overlap with NEO-97 manual QA** | NEO-98 supersedes as capstone; NEO-97 stays component regression (elite incoming-threat row). | **adopted** | From 817ed2630418e9babbf6ab810c6f7bc87a88e700 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:04:18 -0400 Subject: [PATCH 03/13] NEO-98: add code review for E5M2-12 capstone docs --- docs/reviews/2026-05-30-NEO-98.md | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/reviews/2026-05-30-NEO-98.md diff --git a/docs/reviews/2026-05-30-NEO-98.md b/docs/reviews/2026-05-30-NEO-98.md new file mode 100644 index 0000000..801b2f2 --- /dev/null +++ b/docs/reviews/2026-05-30-NEO-98.md @@ -0,0 +1,56 @@ +# Code review — NEO-98 (E5M2-12) + +**Date:** 2026-05-30 +**Scope:** Branch `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` vs `origin/main` — commits `ceef54c` … `ae8b01d` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-98 closes **E5M2-12** and Epic 5 Slice 2 client acceptance with a **docs-only** capstone: `docs/manual-qa/NEO-98.md` (Melee full telegraph spine → Ranged/Elite defeat + telegraph visibility), `client/README.md` end-to-end loop section, implementation plan with kickoff decisions, and module/backlog/alignment/register updates marking **E5.M2 Ready**. No client or server code changes — consistent with kickoff (“docs + manual QA primary; fix gaps only if QA fails on `main`”) and server test evidence cited in the plan (`NpcRuntimeOperationsTests`, `NpcRuntimeSnapshotWorldApiTests` at 4.5 s / **85** HP). Residual risk is low and operational: a long single-session script (~20 steps, elite ×8 casts) and reliance on human Godot verification before merge; timing math in the checklist matches frozen catalog values and server tests. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-98-implementation-plan.md` | **Matches** — kickoff decisions (docs-primary, fresh restart, Melee→Ranged→Elite, melee full / ranged+elite light, NEO-97 as component regression), scope, file list, and reconciliation align with the diff; no integration fixes shipped. | +| `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Partially matches** — landed note and three AC items checked; plan’s fourth AC (“all three archetypes defeated”) is absent from backlog AC list (see Suggestions). | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — Status **Ready** with E5M2-11 + E5M2-12 landed; capstone cross-link + manual QA pointer added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row **Ready**, **NEO-98 landed**, Slice 2 client capstone complete. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M2 note summarizes NEO-92–NEO-98 and marks Slice 2 complete. | +| `docs/manual-qa/NEO-98.md` | **Matches** — single-session Godot script, archetype table, melee defeat math, zero Bruno/curl in main checklist; acceptance boxes intentionally unchecked pre-human QA (NEO-86 precedent). | +| `client/README.md` | **Matches** — **End-to-end NPC telegraph combat loop (NEO-98)** section after NEO-97; flow table, cross-links, script list, capstone manual QA link. | +| `docs/decomposition/epics/epic_05_pve_combat.md` (Slice 2) | **Matches** — backlog pointer to NEO-98 + Godot verification; no epic doc edit required for this story. | +| Full-stack epic decomposition | **Matches** — client capstone **NEO-98** with Godot manual QA; not Bruno-only prototype proof. | + +## Blocking issues + +None. + +## Suggestions + +1. **Regression step — clear lock before `invalid_target` check** — After step 18 the tester is still locked on defeated **`prototype_npc_elite`**; step 19 (“cast without lock”) will hit **`target_defeated`**, not **`invalid_target`**, unless lock is cleared first. Add an **Esc** step (mirror [NEO-86 manual QA](../manual-qa/NEO-86.md) steps 8–10) before the NEO-28 regression assertion. + +2. **E5M2-12 backlog AC parity** — [NEO-98 implementation plan](../plans/NEO-98-implementation-plan.md) lists a fourth AC (“All three archetypes defeated… **`CombatTargetHpLabel`** shows defeated for each”); [E5M2-prototype-backlog.md](../plans/E5M2-prototype-backlog.md) E5M2-12 section omits it. Add the checkbox for traceability with the plan and manual QA steps 9/14/18. + +## Nits + +- Nit: **E5.M2 Summary `Linear` row** still abbreviates **`E5M2-05 … → E5M2-12`** while the **Status** row now lists E5M2-06–E5M2-11 inline — optional consistency pass. + +- Nit: Manual QA / README use shorthand **`100/100`** on **`CombatTargetHpLabel`**; NEO-85 format includes **`Target HP: {id}`** prefix — testers should match substring, not exact label text (or tighten one example step to full format). + +- Nit: Plan / backlog AC boxes are **`[x]`** while **`docs/manual-qa/NEO-98.md` Acceptance** stays **`[ ]`** until a human run — intentional per NEO-86; keep that distinction when pasting PR text. + +## Verification + +```bash +# Server timing spine (no client required — confirms melee telegraph math cited in plan) +cd server/NeonSprawl.Server.Tests && dotnet test --filter "FullyQualifiedName~NpcRuntime" +``` + +**Manual (required before merge):** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — fresh server restart, Godot **F5**, Melee steps 3–10 (telegraph visible **before** **85/100**), Ranged + Elite defeat with telegraph visible, regression steps 19–20. + +**Optional component regression:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) — elite incoming-threat row when locking a different NPC while elite holds aggro. From b03128a3b0b3ba951320e0a8bcbff80590f2cfb8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:05:30 -0400 Subject: [PATCH 04/13] =?UTF-8?q?NEO-98:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20Esc=20regression=20step=20and=20AC=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/README.md | 4 ++-- .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 2 +- docs/manual-qa/NEO-98.md | 10 ++++++---- docs/plans/E5M2-prototype-backlog.md | 1 + docs/reviews/2026-05-30-NEO-98.md | 12 ++++++------ 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/client/README.md b/client/README.md index 45ff794..01da5c4 100644 --- a/client/README.md +++ b/client/README.md @@ -172,11 +172,11 @@ Epic 5 Slice 2 capstone — aggro, readable telegraph, incoming NPC damage, and | Step | Input / trigger | HUD / server outcome | |------|-----------------|----------------------| | Boot | Godot **F5** + fresh server | Hotbar slot 0 binds **`prototype_pulse`**; player combat HP **100/100** when poll starts | -| Melee lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** **`100/100`** | +| Melee lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** **`Target HP: prototype_npc_melee 100/100`** | | Melee aggro | **1** (damaging cast) | **`NpcStateLabel`** → **`aggro`**; combat poll ~1 Hz | | Melee telegraph | Wait **≥ 3 s** | **`telegraph_windup`** + **`TelegraphLabel`** countdown | | Melee hit | Wait **≥ 4.5 s** from first cast | **`PlayerCombatHpLabel`** → **`85/100`** (15 damage) | -| Melee defeat | **1** ×4 total (cooldown between) | **`CombatTargetHpLabel`** **`0/100 (defeated)`** | +| Melee defeat | **1** ×4 total (cooldown between) | **`CombatTargetHpLabel`** includes **`0/100 (defeated)`** | | Ranged | Lock + cast ×4; wait **≥ 4 s** for telegraph | Defeat at **80** HP; telegraph visible before damage | | Elite | Lock + cast ×8; wait **≥ 5 s** for telegraph | Defeat at **200** HP; telegraph visible before damage | diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 9e03b90..194d066 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -8,7 +8,7 @@ | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | | **Status** | Ready — 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-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) client telegraph HUD landed · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) playable capstone landed | -| **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) | +| **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-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | ## Purpose diff --git a/docs/manual-qa/NEO-98.md b/docs/manual-qa/NEO-98.md index 545e4f0..cc82bf4 100644 --- a/docs/manual-qa/NEO-98.md +++ b/docs/manual-qa/NEO-98.md @@ -13,6 +13,7 @@ - **Fresh dev player:** stop any running server, then start a new instance so in-memory **`dev-local-1`** NPC HP, aggro/runtime rows, and player combat HP reset. - **No Bruno/curl seeding** for this checklist — hotbar **`prototype_pulse`** bind is automatic (NEO-85 dev bootstrap). - NEO-97 telegraph HUD landed on `main` (PR #136). +- **`CombatTargetHpLabel`** uses NEO-85 format **`Target HP: {id} {current}/{max}`** (e.g. **`Target HP: prototype_npc_melee 100/100`**) — match HP substring or full line. ## Archetype reference @@ -44,7 +45,7 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). ### Melee — full telegraph + player HP spine 3. Walk to orange **Melee** marker west of spawn **`(-3, -3)`**. -4. Press **Tab** until **`prototype_npc_melee`** is locked with **`Validity: ok`** — marker **brightens**; **`CombatTargetHpLabel`** shows **`100/100`**. +4. Press **Tab** until **`prototype_npc_melee`** is locked with **`Validity: ok`** — marker **brightens**; **`CombatTargetHpLabel`** shows **`Target HP: prototype_npc_melee 100/100`** (or **`…`** briefly until GET completes). 5. Press **1** (damaging cast): **`CastFeedbackLabel`** shows damage; **`NpcStateLabel`** eventually shows **`NPC state: Melee → aggro`** (~1 Hz poll). 6. **`PlayerCombatHpLabel`** shows **`Player HP: 100/100`** once combat poll starts. 7. Wait **≥ 3 s** from first cast: **`NpcStateLabel`** shows **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (e.g. **`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. @@ -68,8 +69,9 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). ### Regression -19. Cast without lock still shows **`ability_cast_denied: invalid_target`** on **`CastFeedbackLabel`** (NEO-28). -20. **`CombatTargetHpLabel`** + cooldown HUD still refresh after accepted casts (NEO-85 / NEO-32). +19. Press **Esc** to clear lock: **`CombatTargetHpLabel`** shows **`Target HP: — (Tab → elite/melee/ranged near spawn)`** (or equivalent no-lock line). +20. Cast without lock: **`CastFeedbackLabel`** shows **`ability_cast_denied: invalid_target`** (NEO-28). +21. **`CombatTargetHpLabel`** + cooldown HUD still refresh after accepted casts (NEO-85 / NEO-32) — re-lock any NPC and press **1** once to confirm. ## Notes @@ -80,7 +82,7 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). ## Acceptance -- [ ] Steps 1–20 completable in one session without Bruno/curl. +- [ ] Steps 1–21 completable in one session without Bruno/curl. - [ ] Melee telegraph countdown readable **before** player HP drops to **85/100** (steps 7–8). - [ ] All three archetypes show **`defeated`** in **`CombatTargetHpLabel`** (steps 9, 14, 18). - [ ] Aggro on first damaging cast feels deterministic (no silent failures). diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 5bc5ed1..09c77bb 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -377,6 +377,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d - [x] Human completes script with server + client; telegraph timing readable before damage. - [x] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA. +- [x] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each (manual QA steps 9, 14, 18). - [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums. **Landed ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)):** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section; plan [NEO-98-implementation-plan.md](NEO-98-implementation-plan.md). **Epic 5 Slice 2 client capstone complete.** diff --git a/docs/reviews/2026-05-30-NEO-98.md b/docs/reviews/2026-05-30-NEO-98.md index 801b2f2..ae7bb23 100644 --- a/docs/reviews/2026-05-30-NEO-98.md +++ b/docs/reviews/2026-05-30-NEO-98.md @@ -17,7 +17,7 @@ NEO-98 closes **E5M2-12** and Epic 5 Slice 2 client acceptance with a **docs-onl | Path | Result | |------|--------| | `docs/plans/NEO-98-implementation-plan.md` | **Matches** — kickoff decisions (docs-primary, fresh restart, Melee→Ranged→Elite, melee full / ranged+elite light, NEO-97 as component regression), scope, file list, and reconciliation align with the diff; no integration fixes shipped. | -| `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Partially matches** — landed note and three AC items checked; plan’s fourth AC (“all three archetypes defeated”) is absent from backlog AC list (see Suggestions). | +| `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Matches** — landed note and four AC items checked (includes three-archetype defeat). | | `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — Status **Ready** with E5M2-11 + E5M2-12 landed; capstone cross-link + manual QA pointer added. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row **Ready**, **NEO-98 landed**, Slice 2 client capstone complete. | | `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M2 note summarizes NEO-92–NEO-98 and marks Slice 2 complete. | @@ -32,15 +32,15 @@ None. ## Suggestions -1. **Regression step — clear lock before `invalid_target` check** — After step 18 the tester is still locked on defeated **`prototype_npc_elite`**; step 19 (“cast without lock”) will hit **`target_defeated`**, not **`invalid_target`**, unless lock is cleared first. Add an **Esc** step (mirror [NEO-86 manual QA](../manual-qa/NEO-86.md) steps 8–10) before the NEO-28 regression assertion. +1. ~~**Regression step — clear lock before `invalid_target` check** — After step 18 the tester is still locked on defeated **`prototype_npc_elite`**; step 19 (“cast without lock”) will hit **`target_defeated`**, not **`invalid_target`**, unless lock is cleared first. Add an **Esc** step (mirror [NEO-86 manual QA](../manual-qa/NEO-86.md) steps 8–10) before the NEO-28 regression assertion.~~ **Done.** Manual QA step 19 **Esc** clear; NEO-28 check is step 20. -2. **E5M2-12 backlog AC parity** — [NEO-98 implementation plan](../plans/NEO-98-implementation-plan.md) lists a fourth AC (“All three archetypes defeated… **`CombatTargetHpLabel`** shows defeated for each”); [E5M2-prototype-backlog.md](../plans/E5M2-prototype-backlog.md) E5M2-12 section omits it. Add the checkbox for traceability with the plan and manual QA steps 9/14/18. +2. ~~**E5M2-12 backlog AC parity** — [NEO-98 implementation plan](../plans/NEO-98-implementation-plan.md) lists a fourth AC (“All three archetypes defeated… **`CombatTargetHpLabel`** shows defeated for each”); [E5M2-prototype-backlog.md](../plans/E5M2-prototype-backlog.md) E5M2-12 section omits it. Add the checkbox for traceability with the plan and manual QA steps 9/14/18.~~ **Done.** ## Nits -- Nit: **E5.M2 Summary `Linear` row** still abbreviates **`E5M2-05 … → E5M2-12`** while the **Status** row now lists E5M2-06–E5M2-11 inline — optional consistency pass. +- ~~Nit: **E5.M2 Summary `Linear` row** still abbreviates **`E5M2-05 … → E5M2-12`** while the **Status** row now lists E5M2-06–E5M2-11 inline — optional consistency pass.~~ **Done.** Linear row lists E5M2-01–E5M2-12 inline. -- Nit: Manual QA / README use shorthand **`100/100`** on **`CombatTargetHpLabel`**; NEO-85 format includes **`Target HP: {id}`** prefix — testers should match substring, not exact label text (or tighten one example step to full format). +- ~~Nit: Manual QA / README use shorthand **`100/100`** on **`CombatTargetHpLabel`**; NEO-85 format includes **`Target HP: {id}`** prefix — testers should match substring, not exact label text (or tighten one example step to full format).~~ **Done.** Preconditions note + step 4 full format; README flow table updated. - Nit: Plan / backlog AC boxes are **`[x]`** while **`docs/manual-qa/NEO-98.md` Acceptance** stays **`[ ]`** until a human run — intentional per NEO-86; keep that distinction when pasting PR text. @@ -51,6 +51,6 @@ None. cd server/NeonSprawl.Server.Tests && dotnet test --filter "FullyQualifiedName~NpcRuntime" ``` -**Manual (required before merge):** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — fresh server restart, Godot **F5**, Melee steps 3–10 (telegraph visible **before** **85/100**), Ranged + Elite defeat with telegraph visible, regression steps 19–20. +**Manual (required before merge):** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — fresh server restart, Godot **F5**, Melee steps 3–10 (telegraph visible **before** **85/100**), Ranged + Elite defeat with telegraph visible, regression steps 19–21 (**Esc** before NEO-28 deny). **Optional component regression:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) — elite incoming-threat row when locking a different NPC while elite holds aggro. From 332cd4445f221a42d3605fde886ae2603cb06f13 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:38:27 -0400 Subject: [PATCH 05/13] NEO-98: stop defeated NPC aggro and runtime attacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear threat holder and reset runtime to idle when combat-target HP is defeated — on cast accept and as an AdvanceOne guard so telegraph cycles cannot continue after the killing blow. --- docs/plans/NEO-98-implementation-plan.md | 13 +- .../Game/AbilityInput/AbilityCastApiTests.cs | 43 +++++ .../Game/Npc/NpcRuntimeOperationsTests.cs | 173 +++++++++++------- .../Game/AbilityInput/AbilityCastApi.cs | 6 + .../Game/Npc/NpcRuntimeOperations.cs | 42 +++++ .../Game/Npc/NpcRuntimeSnapshotWorldApi.cs | 2 + server/README.md | 1 + 7 files changed, 213 insertions(+), 67 deletions(-) diff --git a/docs/plans/NEO-98-implementation-plan.md b/docs/plans/NEO-98-implementation-plan.md index af4bbac..a5dc32e 100644 --- a/docs/plans/NEO-98-implementation-plan.md +++ b/docs/plans/NEO-98-implementation-plan.md @@ -54,7 +54,7 @@ - **`docs/manual-qa/NEO-98.md`** — single-session capstone (Melee full telegraph spine → Ranged/Elite defeat + telegraph visible); zero Bruno/curl in main checklist. - **`client/README.md`** — **End-to-end NPC telegraph combat loop (NEO-98)** section with flow table and cross-links. - **`docs/plans/E5M2-prototype-backlog.md`**, **`E5_M2_NpcAiAndBehaviorProfiles.md`**, **`documentation_and_implementation_alignment.md`** — E5M2-12 landed; E5.M2 **Ready**; Epic 5 Slice 2 client capstone complete. -- **Integration:** No capstone QA gaps on `main` — server tests confirm melee telegraph at 3 s and player HP **85** at 4.5 s (`NpcRuntimeOperationsTests`, `NpcRuntimeSnapshotWorldApiTests`); NEO-97 HUD wiring sufficient. +- **Integration fix (capstone QA):** defeated NPCs continued attacking — **`NpcRuntimeOperations.TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard; server tests added. ## Technical approach @@ -138,8 +138,12 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — NEO-98 landed + Slice 2 client capstone complete (on story completion). | | `docs/plans/E5M2-prototype-backlog.md` | E5M2-12 acceptance checkboxes + landed note (on story completion). | | `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | E5M2-12 capstone landed note + manual QA link (on story completion). | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | **`TryStopOnTargetDefeat`** + defeated guard in **`AdvanceOne`**; **`AdvanceAll`** takes **`ICombatEntityHealthStore`**. | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | Pass combat health store into **`AdvanceAll`**. | +| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Stop NPC combat on **`TargetDefeated`**. | +| `server/README.md` | Threat table — **Defeat clear** rule. | -**Conditional (only if capstone QA fails on `main`):** +**Conditional (only if capstone QA fails on client):** | Path | Rationale | |------|-----------| @@ -150,7 +154,8 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix | Path | Change | |------|--------| -| *(none expected)* | Capstone is docs + manual QA; NEO-97 GdUnit suites already cover poll clients, windup interpolation, and HUD helpers. | +| `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs` | **`TryStopOnTargetDefeat`** + defeated **`AdvanceAll`** guard tests. | +| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | **`PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated`** integration test. | **If integration fix adds/changes client behavior:** add or extend the smallest GdUnit test in the matching NEO-97 suite (`npc_combat_hud_refresh_test.gd`, etc.) — AAA layout per [testing-expectations.md](../../.cursor/rules/testing-expectations.md). @@ -160,7 +165,7 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix | Question or risk | Agent recommendation | Status | |------------------|----------------------|--------| -| **Capstone dry-run on `main`** | Server integration tests confirm melee telegraph + player damage timeline; NEO-97 client HUD sufficient — no client code changes. | **adopted** | +| **Capstone dry-run on `main`** | Capstone QA found defeated melee still attacking; server fix clears aggro + runtime on defeat. | **adopted** | | **Session length (elite ×8 casts)** | Document cast counts + cooldown reminders; elite section is defeat + telegraph visibility only (kickoff depth decision). | **adopted** | | **Player HP drift across three fights** | Script asserts melee **85/100** only; later archetype hits optional in Notes — do not require exact final HP in AC. | **adopted** | | **Overlap with NEO-97 manual QA** | NEO-98 supersedes as capstone; NEO-97 stays component regression (elite incoming-threat row). | **adopted** | diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index 1c5af70..e2d6f2d 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -652,6 +652,49 @@ public sealed class AbilityCastApiTests Assert.Null(fifth.CombatResolution); } + [Fact] + public async Task PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + Assert.NotNull(factory.FakeClock); + await BindSlot0PulseAsync(client); + await LockPrototypeTargetAlphaAsync(client); + var cast = PulseCastRequest(); + for (var i = 0; i < 3; i++) + { + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + response.EnsureSuccessStatusCode(); + factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50)); + } + + // Act + var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast); + fourthResponse.EnsureSuccessStatusCode(); + var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot"); + factory.FakeClock.Advance(TimeSpan.FromSeconds(30)); + _ = await client.GetAsync("/game/world/npc-runtime-snapshot"); + var healthAfterWaitResponse = await client.GetAsync("/game/players/dev-local-1/combat-health"); + + // Assert + var fourth = await fourthResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(fourth); + Assert.True(fourth!.CombatResolution!.TargetDefeated); + snapshotResponse.EnsureSuccessStatusCode(); + var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(snapshotBody); + var melee = snapshotBody!.NpcInstances.Single( + row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId); + Assert.Equal("idle", melee.State); + Assert.Null(melee.AggroHolderPlayerId); + Assert.Null(melee.ActiveTelegraph); + healthAfterWaitResponse.EnsureSuccessStatusCode(); + var healthBody = await healthAfterWaitResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(healthBody); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp); + } + [Fact] public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression() { diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 572eabb..1d8101b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -9,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, IPlayerCombatHealthStore PlayerHealth) + private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, InMemoryCombatEntityHealthStore CombatHealth, IPlayerCombatHealthStore PlayerHealth) CreateFixture() { return ( new InMemoryNpcRuntimeStateStore(), new InMemoryThreatStateStore(), PrototypeNpcTestFixtures.CreateBehaviorRegistry(), + PrototypeNpcTestFixtures.CreateHealthStore(), new InMemoryPlayerCombatHealthStore()); } @@ -27,6 +28,7 @@ public sealed class NpcRuntimeOperationsTests INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + InMemoryCombatEntityHealthStore combatHealthStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => NpcRuntimeOperations.AdvanceAll( @@ -34,6 +36,7 @@ public sealed class NpcRuntimeOperationsTests runtimeStore, threatStore, behaviorRegistry, + combatHealthStore, playerHealthStore, maxDeltaSeconds); @@ -41,9 +44,9 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); // Act - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -54,10 +57,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -69,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); runtime.LastAdvancedUtc = T0; // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -83,11 +86,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -99,11 +102,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(2.9), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -113,12 +116,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -128,13 +131,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, 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); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -146,13 +149,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -164,11 +167,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 10); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 10); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -180,13 +183,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(3.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -201,11 +204,11 @@ public sealed class NpcRuntimeOperationsTests double attackCooldownSeconds) { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -221,12 +224,12 @@ public sealed class NpcRuntimeOperationsTests double telegraphWindupSeconds) { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -239,11 +242,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -255,12 +258,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(100), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); // Act - Advance(T0.AddSeconds(100.5), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(100.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); @@ -272,14 +275,14 @@ public sealed class NpcRuntimeOperationsTests public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); runtime.LastAdvancedUtc = T0.AddHours(1); NpcRuntimeOperations.ResetAllPrototypeRows(runtime); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -291,11 +294,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); - Advance(T0, runtime, threat, behavior, playerHealth); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); runtime.LastAdvancedUtc = T0.AddSeconds(10); // Act - Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(5), runtime, threat, behavior, combatHealth, playerHealth); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); } @@ -304,13 +307,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, 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); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(85, snapshot.CurrentHp); @@ -320,13 +323,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); @@ -359,4 +362,48 @@ public sealed class NpcRuntimeOperationsTests Assert.False(windupOk); Assert.True(validOk); } + + [Fact] + public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime() + { + // Arrange + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); + // Act + NpcRuntimeOperations.TryStopOnTargetDefeat( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + combatHealth, + threat, + runtime); + // Assert + threat.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threatRow); + Assert.Null(threatRow.AggroHolderPlayerId); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeRow); + Assert.Equal(NpcBehaviorState.Idle, runtimeRow.BehaviorState); + Assert.Null(runtimeRow.ActiveTelegraph); + } + + [Fact] + public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated() + { + // Arrange + var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); + NpcRuntimeOperations.TryStopOnTargetDefeat( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + combatHealth, + threat, + runtime); + // Act + Advance(T0.AddSeconds(30), runtime, threat, behavior, combatHealth, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 323d5b5..cb94318 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -66,6 +66,7 @@ public static class AbilityCastApi ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + INpcRuntimeStateStore npcRuntimeStore, IPlayerGigProgressionStore gigStore, ILoggerFactory loggerFactory, TimeProvider clock) => @@ -243,6 +244,11 @@ public static class AbilityCastApi if (combatResult.TargetDefeated) { + NpcRuntimeOperations.TryStopOnTargetDefeat( + lookupKey, + healthStore, + threatStore, + npcRuntimeStore); CombatDefeatGigXpGrant.GrantOnCombatDefeat( id.Trim(), gigStore, diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index e138945..104cc5f 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -20,6 +20,7 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + ICombatEntityHealthStore combatHealthStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = DefaultMaxDeltaSeconds) { @@ -40,6 +41,7 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + combatHealthStore, playerHealthStore); } @@ -59,6 +61,7 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + combatHealthStore, playerHealthStore); } @@ -78,6 +81,7 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + combatHealthStore, playerHealthStore); } @@ -92,6 +96,27 @@ public static class NpcRuntimeOperations runtimeStore.ResetAllPrototypeRows(); } + /// + /// Clears aggro and runtime combat state when the NPC combat-target row is . + /// + public static void TryStopOnTargetDefeat( + string npcInstanceId, + ICombatEntityHealthStore combatHealthStore, + IThreatStateStore threatStore, + INpcRuntimeStateStore runtimeStore) + { + var npcKey = NormalizeNpcId(npcInstanceId); + if (npcKey.Length == 0 || + !combatHealthStore.TryGet(npcKey, out var combatHp) || + !combatHp.Defeated) + { + return; + } + + threatStore.TryClearHolder(npcKey); + WriteIdle(runtimeStore, npcKey); + } + private static void AdvanceOne( string npcInstanceId, DateTimeOffset windowStart, @@ -99,6 +124,7 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + ICombatEntityHealthStore combatHealthStore, IPlayerCombatHealthStore playerHealthStore) { if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || @@ -117,6 +143,19 @@ public static class NpcRuntimeOperations return; } + if (combatHealthStore.TryGet(npcInstanceId, out var combatHp) && combatHp.Defeated) + { + if (TryHasAggroHolder(npcInstanceId, threatStore, out _) || + snapshot.BehaviorState != NpcBehaviorState.Idle || + snapshot.ActiveTelegraph is not null) + { + threatStore.TryClearHolder(npcInstanceId); + WriteIdle(runtimeStore, npcInstanceId); + } + + return; + } + if (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId)) { if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null) @@ -326,4 +365,7 @@ public static class NpcRuntimeOperations npcInstanceId, new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph)); } + + private static string NormalizeNpcId(string? raw) => + raw?.Trim().ToLowerInvariant() ?? string.Empty; } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs index c565a94..5601a4a 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -14,6 +14,7 @@ public static class NpcRuntimeSnapshotWorldApi INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + ICombatEntityHealthStore combatHealthStore, IPlayerCombatHealthStore playerHealthStore) => { var now = clock.GetUtcNow(); @@ -22,6 +23,7 @@ public static class NpcRuntimeSnapshotWorldApi runtimeStore, threatStore, behaviorRegistry, + combatHealthStore, playerHealthStore); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry)); }); diff --git a/server/README.md b/server/README.md index 9fb9939..b0b89ba 100644 --- a/server/README.md +++ b/server/README.md @@ -162,6 +162,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In |------|----------| | **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | | **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | +| **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). | | **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | | **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). | From 3169106f28e21a97b26978f1c1b230f662fdd368 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:49:21 -0400 Subject: [PATCH 06/13] NEO-98: Resolve NPC telegraph hits via attack ability maxRange. Telegraph damage now looks up behavior attackAbilityId for baseDamage and maxRange instead of using aggroRadius. Adds three NPC attack abilities, maxRange on AbilityDef, attackAbilityId on NpcBehaviorDef, and cross-ref validation. --- content/abilities/prototype_abilities.json | 4 + .../abilities/prototype_npc_abilities.json | 29 +++ .../prototype_npc_behaviors.json | 9 +- content/schemas/ability-def.schema.json | 7 +- content/schemas/npc-behavior-def.schema.json | 10 +- scripts/validate_content.py | 80 ++++++-- .../AbilityDefinitionCatalogLoaderTests.cs | 69 ++++++- .../Combat/AbilityDefinitionRegistryTests.cs | 20 +- .../Combat/AbilityDefinitionsWorldApiTests.cs | 21 +- .../Game/Combat/CombatOperationsTests.cs | 4 + .../Game/Combat/NpcAttackOperationsTests.cs | 87 +++++++- ...NpcBehaviorDefinitionCatalogLoaderTests.cs | 12 +- .../Npc/NpcBehaviorDefinitionRegistryTests.cs | 10 +- .../Game/Npc/NpcRuntimeOperationsTests.cs | 190 +++++++++++------- .../Game/Npc/PrototypeNpcTestFixtures.cs | 69 ++++++- .../Game/Combat/AbilityDefRow.cs | 1 + .../Combat/AbilityDefinitionCatalogLoader.cs | 3 +- .../Game/Combat/AbilityDefinitionsListDtos.cs | 3 + .../Game/Combat/AbilityDefinitionsWorldApi.cs | 1 + .../Game/Combat/NpcAttackOperations.cs | 48 ++++- .../PrototypeE5M1AbilityCatalogRules.cs | 27 ++- .../PrototypeNpcAttackAbilityRegistry.cs | 9 + ...aviorCatalogServiceCollectionExtensions.cs | 13 +- .../Game/Npc/NpcBehaviorDefRow.cs | 3 +- .../Npc/NpcBehaviorDefinitionCatalogLoader.cs | 4 +- .../Npc/NpcBehaviorDefinitionsListDtos.cs | 3 + .../Npc/NpcBehaviorDefinitionsWorldApi.cs | 1 + .../Game/Npc/NpcRuntimeOperations.cs | 19 +- .../Game/Npc/NpcRuntimeSnapshotWorldApi.cs | 5 + .../PrototypeE5M2NpcBehaviorCatalogRules.cs | 28 +++ server/README.md | 11 +- 31 files changed, 654 insertions(+), 146 deletions(-) create mode 100644 content/abilities/prototype_npc_abilities.json create mode 100644 server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs diff --git a/content/abilities/prototype_abilities.json b/content/abilities/prototype_abilities.json index 95aac81..3ad79d1 100644 --- a/content/abilities/prototype_abilities.json +++ b/content/abilities/prototype_abilities.json @@ -5,6 +5,7 @@ "id": "prototype_pulse", "displayName": "Prototype Pulse", "baseDamage": 25, + "maxRange": 6.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, @@ -12,6 +13,7 @@ "id": "prototype_guard", "displayName": "Prototype Guard", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 6.0, "abilityKind": "utility" }, @@ -19,6 +21,7 @@ "id": "prototype_dash", "displayName": "Prototype Dash", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 4.0, "abilityKind": "movement" }, @@ -26,6 +29,7 @@ "id": "prototype_burst", "displayName": "Prototype Burst", "baseDamage": 40, + "maxRange": 6.0, "cooldownSeconds": 5.0, "abilityKind": "attack" } diff --git a/content/abilities/prototype_npc_abilities.json b/content/abilities/prototype_npc_abilities.json new file mode 100644 index 0000000..787c6fc --- /dev/null +++ b/content/abilities/prototype_npc_abilities.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_npc_melee_strike", + "displayName": "NPC Melee Strike", + "baseDamage": 15, + "maxRange": 6.0, + "cooldownSeconds": 3.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_ranged_shot", + "displayName": "NPC Ranged Shot", + "baseDamage": 12, + "maxRange": 10.0, + "cooldownSeconds": 4.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_elite_slam", + "displayName": "NPC Elite Slam", + "baseDamage": 25, + "maxRange": 8.0, + "cooldownSeconds": 5.0, + "abilityKind": "attack" + } + ] +} diff --git a/content/npc-behaviors/prototype_npc_behaviors.json b/content/npc-behaviors/prototype_npc_behaviors.json index aa2b1df..4afd2b1 100644 --- a/content/npc-behaviors/prototype_npc_behaviors.json +++ b/content/npc-behaviors/prototype_npc_behaviors.json @@ -10,7 +10,8 @@ "leashRadius": 16.0, "telegraphWindupSeconds": 1.5, "attackDamage": 15, - "attackCooldownSeconds": 3.0 + "attackCooldownSeconds": 3.0, + "attackAbilityId": "prototype_npc_melee_strike" }, { "id": "prototype_ranged_control", @@ -21,7 +22,8 @@ "leashRadius": 20.0, "telegraphWindupSeconds": 2.0, "attackDamage": 12, - "attackCooldownSeconds": 4.0 + "attackCooldownSeconds": 4.0, + "attackAbilityId": "prototype_npc_ranged_shot" }, { "id": "prototype_elite_mini_boss", @@ -32,7 +34,8 @@ "leashRadius": 18.0, "telegraphWindupSeconds": 2.5, "attackDamage": 25, - "attackCooldownSeconds": 5.0 + "attackCooldownSeconds": 5.0, + "attackAbilityId": "prototype_npc_elite_slam" } ] } diff --git a/content/schemas/ability-def.schema.json b/content/schemas/ability-def.schema.json index 41b77b2..f404ca7 100644 --- a/content/schemas/ability-def.schema.json +++ b/content/schemas/ability-def.schema.json @@ -5,7 +5,7 @@ "description": "Single combat ability row for catalogs (e.g. content/abilities/*_abilities.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M1_CombatRulesEngine.md.", "type": "object", "additionalProperties": false, - "required": ["id", "displayName", "baseDamage", "cooldownSeconds"], + "required": ["id", "displayName", "baseDamage", "maxRange", "cooldownSeconds"], "properties": { "id": { "type": "string", @@ -22,6 +22,11 @@ "minimum": 0, "description": "Deterministic damage dealt on successful combat resolve in prototype Slice 1 (no miss/crit RNG)." }, + "maxRange": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Horizontal maximum reach for this ability at combat resolve (XZ-only at runtime)." + }, "cooldownSeconds": { "type": "number", "exclusiveMinimum": 0, diff --git a/content/schemas/npc-behavior-def.schema.json b/content/schemas/npc-behavior-def.schema.json index f03d55a..53015cf 100644 --- a/content/schemas/npc-behavior-def.schema.json +++ b/content/schemas/npc-behavior-def.schema.json @@ -14,7 +14,8 @@ "leashRadius", "telegraphWindupSeconds", "attackDamage", - "attackCooldownSeconds" + "attackCooldownSeconds", + "attackAbilityId" ], "properties": { "id": { @@ -40,7 +41,7 @@ "aggroRadius": { "type": "number", "exclusiveMinimum": 0, - "description": "Horizontal proximity radius for re-aggro after leash clear (XZ-only at runtime)." + "description": "Horizontal proximity radius for future re-aggro (XZ-only at runtime; must be less than leashRadius). Distinct from ability maxRange." }, "leashRadius": { "type": "number", @@ -61,6 +62,11 @@ "type": "number", "exclusiveMinimum": 0, "description": "Seconds after last attack before the NPC may enter telegraph again." + }, + "attackAbilityId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Immutable ability id used at telegraph resolve for maxRange and baseDamage (must exist in ability catalog)." } } } diff --git a/scripts/validate_content.py b/scripts/validate_content.py index b56254b..edeb641 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -105,6 +105,17 @@ PROTOTYPE_E5M1_ABILITY_IDS = frozenset( } ) +# Epic 5 Slice 2 NPC attack abilities (NEO-98): exact ids after schema passes. +PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS = frozenset( + { + "prototype_npc_melee_strike", + "prototype_npc_ranged_shot", + "prototype_npc_elite_slam", + } +) + +PROTOTYPE_ABILITY_CATALOG_IDS = PROTOTYPE_E5M1_ABILITY_IDS | PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS + # Epic 5 Slice 2 prototype lock (NEO-87): exact npc behavior ids after schema passes. # Keep in sync with E5.M2 freeze table and future NEO-88 server loader. PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset( @@ -428,10 +439,11 @@ def _validate_ability_catalogs( *, ability_files: list[Path], ability_validator: Draft202012Validator, -) -> tuple[int, dict[str, str]]: - """Validate ability JSON files. Returns (error_count, seen_ids).""" +) -> tuple[int, dict[str, str], dict[str, dict]]: + """Validate ability JSON files. Returns (error_count, seen_ids, id_to_row).""" errors = 0 seen_ids: dict[str, str] = {} + id_to_row: dict[str, dict] = {} for path in ability_files: rel = str(path.relative_to(REPO_ROOT)) @@ -467,19 +479,25 @@ def _validate_ability_catalogs( errors += 1 else: seen_ids[aid] = rel + id_to_row[aid] = row - return errors, seen_ids + return errors, seen_ids, id_to_row + + +def _prototype_ability_catalog_gate(seen_ids: dict[str, str]) -> str | None: + """Return a human-readable error if prototype ability catalog contract fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_ABILITY_CATALOG_IDS: + return ( + "error: prototype ability catalog expects exactly ability ids " + f"{sorted(PROTOTYPE_ABILITY_CATALOG_IDS)!r}, got {sorted(ids)!r}" + ) + return None def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None: - """Return a human-readable error if E5M1 ability contract fails, else None.""" - ids = frozenset(seen_ids.keys()) - if ids != PROTOTYPE_E5M1_ABILITY_IDS: - return ( - "error: prototype E5M1 expects exactly ability ids " - f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}" - ) - return None + """Backward-compatible alias for the combined prototype ability catalog gate.""" + return _prototype_ability_catalog_gate(seen_ids) def _validate_npc_behavior_catalogs( @@ -555,6 +573,30 @@ def _prototype_e5m2_npc_behavior_numeric_gate(id_to_row: dict[str, dict]) -> str return None +def _prototype_e5m2_npc_behavior_attack_ability_gate( + id_to_row: dict[str, dict], + ability_id_to_row: dict[str, dict], +) -> str | None: + """Return a human-readable error when attackAbilityId is missing or attackDamage mismatches.""" + for bid, row in id_to_row.items(): + aid = row.get("attackAbilityId") + if not isinstance(aid, str): + continue + ability = ability_id_to_row.get(aid) + if ability is None: + return ( + f"error: npc behavior {bid!r}: attackAbilityId {aid!r} missing from ability catalog" + ) + attack_damage = row.get("attackDamage") + base_damage = ability.get("baseDamage") + if isinstance(attack_damage, int) and isinstance(base_damage, int) and attack_damage != base_damage: + return ( + f"error: npc behavior {bid!r}: attackDamage {attack_damage} must match " + f"ability {aid!r} baseDamage {base_damage}" + ) + return None + + def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None: """Return a human-readable error if Slice 4 contract fails, else None.""" if len(track_skill_ids) != 1: @@ -1161,7 +1203,7 @@ def main() -> int: print(slice3_recipe_err, file=sys.stderr) return 1 - ability_errors, ability_seen_ids = _validate_ability_catalogs( + ability_errors, ability_seen_ids, ability_id_to_row = _validate_ability_catalogs( ability_files=ability_files, ability_validator=ability_validator, ) @@ -1169,9 +1211,9 @@ def main() -> int: print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr) return 1 - e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids) - if e5m1_ability_err: - print(e5m1_ability_err, file=sys.stderr) + ability_catalog_err = _prototype_ability_catalog_gate(ability_seen_ids) + if ability_catalog_err: + print(ability_catalog_err, file=sys.stderr) return 1 npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs( @@ -1192,6 +1234,14 @@ def main() -> int: print(e5m2_npc_behavior_numeric_err, file=sys.stderr) return 1 + e5m2_attack_ability_err = _prototype_e5m2_npc_behavior_attack_ability_gate( + npc_behavior_id_to_row, + ability_id_to_row, + ) + if e5m2_attack_ability_err: + print(e5m2_attack_ability_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), " diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs index 74041ee..b7964eb 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs @@ -21,6 +21,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_pulse", "displayName": "Prototype Pulse", "baseDamage": 25, + "maxRange": 6.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, @@ -28,6 +29,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_guard", "displayName": "Prototype Guard", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 6.0, "abilityKind": "utility" }, @@ -35,6 +37,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_dash", "displayName": "Prototype Dash", "baseDamage": 0, + "maxRange": 6.0, "cooldownSeconds": 4.0, "abilityKind": "movement" }, @@ -42,6 +45,40 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_burst", "displayName": "Prototype Burst", "baseDamage": 40, + "maxRange": 6.0, + "cooldownSeconds": 5.0, + "abilityKind": "attack" + } + ] + } + """; + + private const string ValidNpcAttackAbilitiesJson = + """ + { + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_npc_melee_strike", + "displayName": "NPC Melee Strike", + "baseDamage": 15, + "maxRange": 6.0, + "cooldownSeconds": 3.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_ranged_shot", + "displayName": "NPC Ranged Shot", + "baseDamage": 12, + "maxRange": 10.0, + "cooldownSeconds": 4.0, + "abilityKind": "attack" + }, + { + "id": "prototype_npc_elite_slam", + "displayName": "NPC Elite Slam", + "baseDamage": 25, + "maxRange": 8.0, "cooldownSeconds": 5.0, "abilityKind": "attack" } @@ -64,6 +101,15 @@ public class AbilityDefinitionCatalogLoaderTests private static void WriteCatalog(string abilitiesDir, string catalogJson) => File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8); + private static void WriteFullPrototypeCatalog(string abilitiesDir) + { + WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + ValidNpcAttackAbilitiesJson, + Encoding.UTF8); + } + private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId) { var abilities = catalogRoot["abilities"] as JsonArray @@ -85,12 +131,12 @@ public class AbilityDefinitionCatalogLoaderTests { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); - WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + WriteFullPrototypeCatalog(abilitiesDir); // Act var catalog = LoadCatalog(abilitiesDir, schemaPath); // Assert - Assert.Equal(4, catalog.DistinctAbilityCount); - Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.Equal(7, catalog.DistinctAbilityCount); + Assert.Equal(2, catalog.CatalogJsonFileCount); Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse)); Assert.NotNull(pulse); Assert.Equal(25, pulse!.BaseDamage); @@ -136,7 +182,7 @@ public class AbilityDefinitionCatalogLoaderTests { // Arrange var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); - WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + WriteFullPrototypeCatalog(abilitiesDir); File.WriteAllText( Path.Combine(abilitiesDir, "extra_abilities.json"), """ @@ -147,6 +193,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_pulse", "displayName": "Duplicate Pulse", "baseDamage": 1, + "maxRange": 6.0, "cooldownSeconds": 1.0 } ] @@ -171,11 +218,15 @@ public class AbilityDefinitionCatalogLoaderTests ?? throw new InvalidOperationException("expected abilities array"); abilities.RemoveAt(abilities.Count - 1); WriteCatalog(abilitiesDir, root.ToJsonString()); + File.WriteAllText( + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + ValidNpcAttackAbilitiesJson, + Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); - Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal); } [Fact] @@ -187,11 +238,15 @@ public class AbilityDefinitionCatalogLoaderTests ?? throw new InvalidOperationException("expected object root"); GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra"; WriteCatalog(abilitiesDir, root.ToJsonString()); + File.WriteAllText( + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + ValidNpcAttackAbilitiesJson, + Encoding.UTF8); // Act var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); // Assert var ioe = Assert.IsType(ex); - Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype ability catalog expects exactly ability ids", ioe.Message, StringComparison.Ordinal); Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal); } @@ -316,7 +371,7 @@ public class AbilityDefinitionCatalogLoaderTests // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); var catalog = factory.Services.GetRequiredService(); - Assert.Equal(4, catalog.DistinctAbilityCount); + Assert.Equal(7, catalog.DistinctAbilityCount); Assert.True(catalog.TryGetAbility("prototype_burst", out var burst)); Assert.Equal(40, burst!.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs index 40a3a60..dcb6123 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs @@ -27,6 +27,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -52,6 +53,7 @@ public class AbilityDefinitionRegistryTests "Prototype Burst", 40, 5.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -75,6 +77,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -96,6 +99,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -117,6 +121,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -138,6 +143,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -159,6 +165,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -180,6 +187,7 @@ public class AbilityDefinitionRegistryTests "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -201,24 +209,28 @@ public class AbilityDefinitionRegistryTests "Prototype Burst", 40, 5.0, + 6.0, "attack"), [PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeDash, "Prototype Dash", 0, 4.0, + 6.0, "movement"), [PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeGuard, "Prototype Guard", 0, 6.0, + 6.0, "utility"), [PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypePulse, "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var registry = CreateRegistryFromRows(rows); @@ -249,6 +261,10 @@ public class AbilityDefinitionRegistryTests Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"), Path.Combine(abilitiesDir, "prototype_abilities.json"), overwrite: true); + File.Copy( + Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_npc_abilities.json"), + Path.Combine(abilitiesDir, "prototype_npc_abilities.json"), + overwrite: true); var loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance); var registry = new AbilityDefinitionRegistry(loaded); // Act @@ -274,7 +290,7 @@ public class AbilityDefinitionRegistryTests Assert.NotNull(burst); Assert.Equal(40, burst!.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); - Assert.Equal(4, list.Count); + Assert.Equal(7, list.Count); Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id); Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id); } @@ -313,7 +329,7 @@ public class AbilityDefinitionRegistryTests Assert.Null(missing); Assert.True(normalized); Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId); - Assert.Equal(4, list.Count); + Assert.Equal(7, list.Count); Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs index 6c27309..238accd 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs @@ -8,17 +8,20 @@ namespace NeonSprawl.Server.Tests.Game.Combat; public class AbilityDefinitionsWorldApiTests { - /// Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno. - public static readonly string[] FrozenFourInIdOrder = + /// Frozen prototype seven in registry id order (ordinal). Keep in sync with Bruno. + public static readonly string[] FrozenSevenInIdOrder = [ "prototype_burst", "prototype_dash", "prototype_guard", + "prototype_npc_elite_slam", + "prototype_npc_melee_strike", + "prototype_npc_ranged_shot", "prototype_pulse", ]; [Fact] - public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenFourInIdOrder() + public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenSevenInIdOrder() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); @@ -31,30 +34,38 @@ public class AbilityDefinitionsWorldApiTests Assert.NotNull(body); Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.NotNull(body.Abilities); - Assert.Equal(4, body.Abilities.Count); + Assert.Equal(7, body.Abilities.Count); var ids = body.Abilities.Select(static a => a.Id).ToList(); - Assert.Equal(FrozenFourInIdOrder, ids); + Assert.Equal(FrozenSevenInIdOrder, ids); var pulse = body.Abilities.Single(a => a.Id == "prototype_pulse"); Assert.Equal("Prototype Pulse", pulse.DisplayName); Assert.Equal(25, pulse.BaseDamage); Assert.Equal(3.0, pulse.CooldownSeconds); + Assert.Equal(6.0, pulse.MaxRange); Assert.Equal("attack", pulse.AbilityKind); var burst = body.Abilities.Single(a => a.Id == "prototype_burst"); Assert.Equal("Prototype Burst", burst.DisplayName); Assert.Equal(40, burst.BaseDamage); Assert.Equal(5.0, burst.CooldownSeconds); + Assert.Equal(6.0, burst.MaxRange); Assert.Equal("attack", burst.AbilityKind); var guard = body.Abilities.Single(a => a.Id == "prototype_guard"); Assert.Equal("utility", guard.AbilityKind); Assert.Equal(0, guard.BaseDamage); Assert.Equal(6.0, guard.CooldownSeconds); + Assert.Equal(6.0, guard.MaxRange); var dash = body.Abilities.Single(a => a.Id == "prototype_dash"); Assert.Equal("movement", dash.AbilityKind); Assert.Equal(0, dash.BaseDamage); Assert.Equal(4.0, dash.CooldownSeconds); + Assert.Equal(6.0, dash.MaxRange); + + var meleeStrike = body.Abilities.Single(a => a.Id == "prototype_npc_melee_strike"); + Assert.Equal(15, meleeStrike.BaseDamage); + Assert.Equal(6.0, meleeStrike.MaxRange); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs index 3be602d..081318c 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs @@ -19,24 +19,28 @@ public sealed class CombatOperationsTests "Prototype Burst", 40, 5.0, + 6.0, "attack"), [PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeDash, "Prototype Dash", 0, 4.0, + 6.0, "movement"), [PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypeGuard, "Prototype Guard", 0, 6.0, + 6.0, "utility"), [PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow( PrototypeAbilityRegistry.PrototypePulse, "Prototype Pulse", 25, 3.0, + 6.0, "attack"), }; var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1); diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs index f8c2084..a6c934f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/NpcAttackOperationsTests.cs @@ -1,23 +1,38 @@ +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Tests.Game.Npc; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; public sealed class NpcAttackOperationsTests { + private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) => + new(Options.Create(new GamePositionOptions + { + DevPlayerId = "dev-local-1", + DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z }, + })); + [Fact] - public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches() + public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatchesAndInRange() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 15, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert @@ -26,19 +41,71 @@ public sealed class NpcAttackOperationsTests Assert.Equal(85, snapshot.CurrentHp); } + [Fact] + public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderOutOfAbilityMaxRange() + { + // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, + 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_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius() + { + // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); + var threatStore = new InMemoryThreatStateStore(); + var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-10.0, 0.5, -3.0); + _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); + // Act + var ok = NpcAttackOperations.TryResolveTelegraphComplete( + PrototypeNpcRegistry.PrototypeNpcMeleeId, + "dev-local-1", + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, + 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_WhenHolderCleared() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); _ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 15, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert @@ -51,14 +118,18 @@ public sealed class NpcAttackOperationsTests public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player"); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 15, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert @@ -68,17 +139,21 @@ public sealed class NpcAttackOperationsTests } [Fact] - public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero() + public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAbilityBaseDamageZero() { // Arrange + var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry(); var threatStore = new InMemoryThreatStateStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore(); + var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); // Act var ok = NpcAttackOperations.TryResolveTelegraphComplete( PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", - attackDamage: 0, + PrototypeAbilityRegistry.PrototypeGuard, + abilityRegistry, + positionStore, playerHealthStore, threatStore); // Assert diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs index b595075..5790a6e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs @@ -26,7 +26,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 16.0, "telegraphWindupSeconds": 1.5, "attackDamage": 15, - "attackCooldownSeconds": 3.0 + "attackCooldownSeconds": 3.0, + "attackAbilityId": "prototype_npc_melee_strike" }, { "id": "prototype_ranged_control", @@ -37,7 +38,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 20.0, "telegraphWindupSeconds": 2.0, "attackDamage": 12, - "attackCooldownSeconds": 4.0 + "attackCooldownSeconds": 4.0, + "attackAbilityId": "prototype_npc_ranged_shot" }, { "id": "prototype_elite_mini_boss", @@ -48,7 +50,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 18.0, "telegraphWindupSeconds": 2.5, "attackDamage": 25, - "attackCooldownSeconds": 5.0 + "attackCooldownSeconds": 5.0, + "attackAbilityId": "prototype_npc_elite_slam" } ] } @@ -159,7 +162,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests "leashRadius": 2.0, "telegraphWindupSeconds": 1.0, "attackDamage": 1, - "attackCooldownSeconds": 1.0 + "attackCooldownSeconds": 1.0, + "attackAbilityId": "prototype_npc_melee_strike" } ] } diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs index 692a5be..e0c50ac 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionRegistryTests.cs @@ -1,6 +1,7 @@ using System.IO; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Tests; using Xunit; @@ -25,7 +26,8 @@ public class NpcBehaviorDefinitionRegistryTests 16.0, 1.5, 15, - 3.0); + 3.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike); private static NpcBehaviorDefRow RangedRow() => new( @@ -37,7 +39,8 @@ public class NpcBehaviorDefinitionRegistryTests 20.0, 2.0, 12, - 4.0); + 4.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot); private static NpcBehaviorDefRow EliteRow() => new( @@ -49,7 +52,8 @@ public class NpcBehaviorDefinitionRegistryTests 18.0, 2.5, 25, - 5.0); + 5.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam); [Fact] public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists() diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 1d8101b..df437ac 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; using Xunit; namespace NeonSprawl.Server.Tests.Game.Npc; @@ -9,14 +11,23 @@ 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, InMemoryCombatEntityHealthStore CombatHealth, IPlayerCombatHealthStore PlayerHealth) - CreateFixture() + private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) => + new(Options.Create(new GamePositionOptions + { + DevPlayerId = "dev-local-1", + DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z }, + })); + + private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IAbilityDefinitionRegistry Abilities, InMemoryCombatEntityHealthStore CombatHealth, InMemoryPositionStateStore Positions, IPlayerCombatHealthStore PlayerHealth) + CreateFixture(double playerX = -3.0, double playerY = 0.5, double playerZ = -3.0) { return ( new InMemoryNpcRuntimeStateStore(), new InMemoryThreatStateStore(), PrototypeNpcTestFixtures.CreateBehaviorRegistry(), + PrototypeNpcTestFixtures.CreateAbilityRegistry(), PrototypeNpcTestFixtures.CreateHealthStore(), + CreatePositionStoreAt(playerX, playerY, playerZ), new InMemoryPlayerCombatHealthStore()); } @@ -28,7 +39,9 @@ public sealed class NpcRuntimeOperationsTests INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, InMemoryCombatEntityHealthStore combatHealthStore, + InMemoryPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => NpcRuntimeOperations.AdvanceAll( @@ -36,7 +49,9 @@ public sealed class NpcRuntimeOperationsTests runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore, maxDeltaSeconds); @@ -44,9 +59,9 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); // Act - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -57,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -72,10 +87,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); runtime.LastAdvancedUtc = T0; // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -86,11 +101,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -102,11 +117,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(2.9), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(2.9), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -116,12 +131,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -131,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -149,13 +164,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -167,11 +182,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 10); + Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 10); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -183,13 +198,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(3.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(3.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -204,11 +219,11 @@ public sealed class NpcRuntimeOperationsTests double attackCooldownSeconds) { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); @@ -224,12 +239,12 @@ public sealed class NpcRuntimeOperationsTests double telegraphWindupSeconds) { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -242,11 +257,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); @@ -258,12 +273,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(100), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(100), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Act - Advance(T0.AddSeconds(100.5), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddSeconds(100.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); @@ -275,14 +290,14 @@ public sealed class NpcRuntimeOperationsTests public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); runtime.LastAdvancedUtc = T0.AddHours(1); NpcRuntimeOperations.ResetAllPrototypeRows(runtime); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); + Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -294,11 +309,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); runtime.LastAdvancedUtc = T0.AddSeconds(10); // Act - Advance(T0.AddSeconds(5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); } @@ -307,13 +322,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(85, snapshot.CurrentHp); @@ -323,13 +338,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); @@ -350,7 +365,8 @@ public sealed class NpcRuntimeOperationsTests 16.0, 1.5, 15, - 0.0); + 0.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike); var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 }; var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 }; // Act @@ -367,10 +383,10 @@ public sealed class NpcRuntimeOperationsTests public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act NpcRuntimeOperations.TryStopOnTargetDefeat( @@ -390,10 +406,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated() { // Arrange - var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); - Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); NpcRuntimeOperations.TryStopOnTargetDefeat( PrototypeNpcRegistry.PrototypeNpcMeleeId, @@ -401,7 +417,37 @@ public sealed class NpcRuntimeOperationsTests threat, runtime); // Act - Advance(T0.AddSeconds(30), runtime, threat, behavior, combatHealth, playerHealth); + Advance(T0.AddSeconds(30), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius() + { + // Arrange + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(-10.0, 0.5, -3.0); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Assert + playerHealth.TryGet("dev-local-1", out var snapshot); + Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); + } + + [Fact] + public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderOutOfAttackRangeAtWindupComplete() + { + // Arrange + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(10.0, 0.5, 10.0); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); + // Act + Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs index 98caf87..da4642b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Npc; @@ -6,6 +7,65 @@ namespace NeonSprawl.Server.Tests.Game.Npc; /// Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91). public static class PrototypeNpcTestFixtures { + public static IAbilityDefinitionRegistry CreateAbilityRegistry() + { + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeAbilityRegistry.PrototypeBurst] = new( + PrototypeAbilityRegistry.PrototypeBurst, + "Prototype Burst", + 40, + 5.0, + 6.0, + "attack"), + [PrototypeAbilityRegistry.PrototypeDash] = new( + PrototypeAbilityRegistry.PrototypeDash, + "Prototype Dash", + 0, + 4.0, + 6.0, + "movement"), + [PrototypeAbilityRegistry.PrototypeGuard] = new( + PrototypeAbilityRegistry.PrototypeGuard, + "Prototype Guard", + 0, + 6.0, + 6.0, + "utility"), + [PrototypeAbilityRegistry.PrototypePulse] = new( + PrototypeAbilityRegistry.PrototypePulse, + "Prototype Pulse", + 25, + 3.0, + 6.0, + "attack"), + [PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike] = new( + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + "NPC Melee Strike", + 15, + 3.0, + 6.0, + "attack"), + [PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot] = new( + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot, + "NPC Ranged Shot", + 12, + 4.0, + 10.0, + "attack"), + [PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam] = new( + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam, + "NPC Elite Slam", + 25, + 5.0, + 8.0, + "attack"), + }; + + var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 2); + return new AbilityDefinitionRegistry(catalog); + } + public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry() { var byId = new Dictionary(StringComparer.Ordinal) @@ -19,7 +79,8 @@ public static class PrototypeNpcTestFixtures 16.0, 1.5, 15, - 3.0), + 3.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike), [PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new( PrototypeNpcBehaviorRegistry.PrototypeRangedControl, "Ranged Control", @@ -29,7 +90,8 @@ public static class PrototypeNpcTestFixtures 20.0, 2.0, 12, - 4.0), + 4.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot), [PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new( PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, "Elite Mini-Boss", @@ -39,7 +101,8 @@ public static class PrototypeNpcTestFixtures 18.0, 2.5, 25, - 5.0), + 5.0, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam), }; var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1); diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs index 34ff4cb..b34df19 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs @@ -6,4 +6,5 @@ public sealed record AbilityDefRow( string DisplayName, int BaseDamage, double CooldownSeconds, + double MaxRange, string? AbilityKind); diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs index 5eb5e83..255d8a2 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs @@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue(); var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue(); + var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue(); string? abilityKind = null; if (rowObj["abilityKind"] is JsonValue abilityKindValue) abilityKind = abilityKindValue.GetValue(); - return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind); + return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, maxRange, abilityKind); } private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs index 036949c..d42f926 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsListDtos.cs @@ -30,6 +30,9 @@ public sealed class AbilityDefinitionJson [JsonPropertyName("cooldownSeconds")] public required double CooldownSeconds { get; init; } + [JsonPropertyName("maxRange")] + public required double MaxRange { get; init; } + [JsonPropertyName("abilityKind")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? AbilityKind { get; init; } diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs index 7d4d28e..c9310a7 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionsWorldApi.cs @@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi DisplayName = d.DisplayName, BaseDamage = d.BaseDamage, CooldownSeconds = d.CooldownSeconds, + MaxRange = d.MaxRange, AbilityKind = d.AbilityKind, }); } diff --git a/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs index c83cba8..a76a7d6 100644 --- a/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs +++ b/server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs @@ -1,22 +1,32 @@ using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; namespace NeonSprawl.Server.Game.Combat; -/// Deterministic NPC telegraph-complete damage resolve (NEO-95). +/// Deterministic NPC telegraph-complete damage resolve (NEO-95 / NEO-98). public static class NpcAttackOperations { /// - /// Applies catalog to when - /// the threat row still names that holder for . + /// Applies catalog ability baseDamage to when the threat row still + /// names that holder for and the holder is within the attack ability's + /// of the NPC anchor. /// public static bool TryResolveTelegraphComplete( string npcInstanceId, string holderPlayerId, - int attackDamage, + string attackAbilityId, + IAbilityDefinitionRegistry abilityRegistry, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, IThreatStateStore threatStore) { - if (attackDamage <= 0) + if (!abilityRegistry.TryGetDefinition(attackAbilityId, out var ability)) + { + return false; + } + + if (ability.BaseDamage <= 0) { return true; } @@ -29,7 +39,12 @@ public static class NpcAttackOperations return false; } - if (!playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out var snapshot)) + if (!IsHolderWithinAbilityMaxRange(npcInstanceId, holderPlayerId, ability.MaxRange, positionStore)) + { + return false; + } + + if (!playerHealthStore.TryApplyDamage(holderPlayerId, ability.BaseDamage, out var snapshot)) { return false; } @@ -41,4 +56,25 @@ public static class NpcAttackOperations return true; } + + internal static bool IsHolderWithinAbilityMaxRange( + string npcInstanceId, + string holderPlayerId, + double maxRange, + IPositionStateStore positionStore) + { + if (maxRange <= 0 || + !PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || + !positionStore.TryGetPosition(holderPlayerId, out var playerSnap)) + { + return false; + } + + return HorizontalReach.IsWithinHorizontalRadius( + playerSnap.X, + playerSnap.Z, + entry.X, + entry.Z, + maxRange); + } } diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs index 30f8b89..f72b648 100644 --- a/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs @@ -3,8 +3,9 @@ using System.Collections.Frozen; namespace NeonSprawl.Server.Game.Combat; /// -/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from scripts/validate_content.py -/// PROTOTYPE_E5M1_ABILITY_IDS / _prototype_e5m1_ability_gate. +/// Prototype ability roster gate (NEO-76 / NEO-77 / NEO-98), mirrored from scripts/validate_content.py +/// PROTOTYPE_E5M1_ABILITY_IDS, PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS, and +/// _prototype_ability_catalog_gate. /// public static class PrototypeE5M1AbilityCatalogRules { @@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules ], StringComparer.Ordinal); - /// Returns a human-readable error if the E5M1 ability contract fails, otherwise . + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS. + public static readonly FrozenSet ExpectedNpcAttackAbilityIds = FrozenSet.ToFrozenSet( + [ + PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot, + PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam, + ], + StringComparer.Ordinal); + + /// Union of player hotbar abilities and NPC attack abilities loaded at startup. + public static readonly FrozenSet ExpectedCatalogAbilityIds = ExpectedAbilityIds + .Union(ExpectedNpcAttackAbilityIds) + .ToFrozenSet(StringComparer.Ordinal); + + /// Returns a human-readable error if the prototype ability catalog contract fails, otherwise . public static string? TryGetE5M1GateError(IReadOnlyDictionary abilityIdToSourceFile) { var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); - if (!ids.SetEquals(ExpectedAbilityIds)) + if (!ids.SetEquals(ExpectedCatalogAbilityIds)) { return - "error: prototype E5M1 expects exactly ability ids " + - $"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + "error: prototype ability catalog expects exactly ability ids " + + $"[{string.Join(", ", ExpectedCatalogAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; } diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs new file mode 100644 index 0000000..4aa3c49 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeNpcAttackAbilityRegistry.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Stable prototype NPC attack ability id constants (NEO-98). Allowlist validation uses . +public static class PrototypeNpcAttackAbilityRegistry +{ + public const string PrototypeNpcMeleeStrike = "prototype_npc_melee_strike"; + public const string PrototypeNpcRangedShot = "prototype_npc_ranged_shot"; + public const string PrototypeNpcEliteSlam = "prototype_npc_elite_slam"; +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs index 9263535..26de6c8 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Npc; @@ -29,7 +30,17 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions opts.NpcBehaviorDefSchemaPath, hostEnv.ContentRootPath); - return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); + var catalog = NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); + var abilityRegistry = sp.GetRequiredService(); + var crossRefErr = PrototypeE5M2NpcBehaviorCatalogRules.TryGetAttackAbilityCrossRefError( + catalog.ById, + abilityRegistry); + if (crossRefErr is not null) + { + throw new InvalidOperationException(crossRefErr); + } + + return catalog; }); services.AddSingleton(sp => diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs index 1f93d9a..9a23957 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs @@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow( double LeashRadius, double TelegraphWindupSeconds, int AttackDamage, - double AttackCooldownSeconds); + double AttackCooldownSeconds, + string AttackAbilityId); diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs index be7cddd..6c16516 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs @@ -152,6 +152,7 @@ public static class NpcBehaviorDefinitionCatalogLoader var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue(); var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue(); var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue(); + var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue(); return new NpcBehaviorDefRow( id, @@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader leashRadius, telegraphWindupSeconds, attackDamage, - attackCooldownSeconds); + attackCooldownSeconds, + attackAbilityId); } private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs index e6c7e1e..c9bb92c 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsListDtos.cs @@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson [JsonPropertyName("attackCooldownSeconds")] public required double AttackCooldownSeconds { get; init; } + + [JsonPropertyName("attackAbilityId")] + public required string AttackAbilityId { get; init; } } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs index 66eb048..1ae23da 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionsWorldApi.cs @@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi TelegraphWindupSeconds = d.TelegraphWindupSeconds, AttackDamage = d.AttackDamage, AttackCooldownSeconds = d.AttackCooldownSeconds, + AttackAbilityId = d.AttackAbilityId, }); } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index 104cc5f..95f15e2 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Npc; @@ -20,7 +21,9 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = DefaultMaxDeltaSeconds) { @@ -41,7 +44,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); } @@ -61,7 +66,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); } @@ -81,7 +88,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); } @@ -124,7 +133,9 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore) { if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || @@ -251,7 +262,9 @@ public static class NpcRuntimeOperations NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, - behavior.AttackDamage, + behavior.AttackAbilityId, + abilityRegistry, + positionStore, playerHealthStore, threatStore); WriteState( @@ -272,7 +285,9 @@ public static class NpcRuntimeOperations NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, - behavior.AttackDamage, + behavior.AttackAbilityId, + abilityRegistry, + positionStore, playerHealthStore, threatStore); WriteState( diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs index 5601a4a..fd614d7 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.PositionState; namespace NeonSprawl.Server.Game.Npc; @@ -14,7 +15,9 @@ public static class NpcRuntimeSnapshotWorldApi INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore) => { var now = clock.GetUtcNow(); @@ -23,7 +26,9 @@ public static class NpcRuntimeSnapshotWorldApi runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, combatHealthStore, + positionStore, playerHealthStore); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry)); }); diff --git a/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs index 3bb2de9..77fb8e8 100644 --- a/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs @@ -1,5 +1,7 @@ using System.Collections.Frozen; +using NeonSprawl.Server.Game.Combat; + namespace NeonSprawl.Server.Game.Npc; /// @@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules return null; } + + /// + /// Returns a human-readable error when is missing from the ability catalog + /// or attackDamage does not match ability baseDamage, otherwise . + /// + public static string? TryGetAttackAbilityCrossRefError( + IReadOnlyDictionary rowsById, + IAbilityDefinitionRegistry abilityRegistry) + { + foreach (var (behaviorId, row) in rowsById) + { + if (!abilityRegistry.TryGetDefinition(row.AttackAbilityId, out var ability)) + { + return + $"error: npc behavior '{behaviorId}': attackAbilityId '{row.AttackAbilityId}' missing from ability catalog"; + } + + if (row.AttackDamage != ability.BaseDamage) + { + return + $"error: npc behavior '{behaviorId}': attackDamage {row.AttackDamage} must match ability '{row.AttackAbilityId}' baseDamage {ability.BaseDamage}"; + } + } + + return null; + } } diff --git a/server/README.md b/server/README.md index b0b89ba..b0f5ab9 100644 --- a/server/README.md +++ b/server/README.md @@ -82,7 +82,7 @@ On success, **Information** logs include the resolved recipes directory path, di ## Ability catalog (`content/abilities`, NEO-77) -On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. +On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype ability catalog** seven-id roster gate (four player hotbar abilities plus three NPC attack abilities; same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. | Config | Meaning | |--------|---------| @@ -108,7 +108,7 @@ On success, **Information** logs include the resolved npc-behaviors directory pa ## NPC behavior definitions (NEO-90) -**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, and **`attackCooldownSeconds`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`. +**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`. ```bash curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions" @@ -161,6 +161,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In | Rule | Behavior | |------|----------| | **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | +| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **6 m**, ranged shot **10 m**, elite slam **8 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | | **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | | **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). | | **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | @@ -177,7 +178,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`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies catalog **`attackDamage`** to aggro holder ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). | +| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies the behavior's **`attackAbilityId`** **`baseDamage`** when holder is within that ability's **`maxRange`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). | | **`recover`** | Post-attack cooldown wait before next windup. | | Rule | Behavior | @@ -209,7 +210,7 @@ Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.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). When windup completes during **`AdvanceAll`**, catalog **`attackDamage`** applies to the aggro holder via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). +**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`**, the behavior's **`attackAbilityId`** **`baseDamage`** applies to the aggro holder when within that ability's **`maxRange`** via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). ## Session player combat HP (NEO-95) @@ -218,7 +219,7 @@ Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPla | 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. | +| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes, aggro holder still matches, and holder is within the attack ability's **`maxRange`** of the NPC anchor. | | **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. | From b9e2d2f4c92b8fb00ccec53e957a30384ad358ef Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:53:14 -0400 Subject: [PATCH 07/13] NEO-98: Shorten NPC melee strike maxRange to 1 m. --- content/abilities/prototype_npc_abilities.json | 2 +- .../Game/Combat/AbilityDefinitionCatalogLoaderTests.cs | 2 +- .../Game/Combat/AbilityDefinitionsWorldApiTests.cs | 2 +- .../Game/Npc/PrototypeNpcTestFixtures.cs | 2 +- server/README.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/content/abilities/prototype_npc_abilities.json b/content/abilities/prototype_npc_abilities.json index 787c6fc..68fe760 100644 --- a/content/abilities/prototype_npc_abilities.json +++ b/content/abilities/prototype_npc_abilities.json @@ -5,7 +5,7 @@ "id": "prototype_npc_melee_strike", "displayName": "NPC Melee Strike", "baseDamage": 15, - "maxRange": 6.0, + "maxRange": 1.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs index b7964eb..6c56055 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs @@ -62,7 +62,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_npc_melee_strike", "displayName": "NPC Melee Strike", "baseDamage": 15, - "maxRange": 6.0, + "maxRange": 1.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs index 238accd..89073a6 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs @@ -66,6 +66,6 @@ public class AbilityDefinitionsWorldApiTests var meleeStrike = body.Abilities.Single(a => a.Id == "prototype_npc_melee_strike"); Assert.Equal(15, meleeStrike.BaseDamage); - Assert.Equal(6.0, meleeStrike.MaxRange); + Assert.Equal(1.0, meleeStrike.MaxRange); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs index da4642b..186a5e2 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs @@ -44,7 +44,7 @@ public static class PrototypeNpcTestFixtures "NPC Melee Strike", 15, 3.0, - 6.0, + 1.0, "attack"), [PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot] = new( PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot, diff --git a/server/README.md b/server/README.md index b0f5ab9..68e7f1b 100644 --- a/server/README.md +++ b/server/README.md @@ -161,7 +161,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In | Rule | Behavior | |------|----------| | **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | -| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **6 m**, ranged shot **10 m**, elite slam **8 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | +| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **1 m**, ranged shot **10 m**, elite slam **8 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | | **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | | **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). | | **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | From 99aa2b455f2284bc716d0d992264d5a6a246160f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:54:06 -0400 Subject: [PATCH 08/13] NEO-98: Document melee 1 m strike range in manual QA. --- docs/manual-qa/NEO-98.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/manual-qa/NEO-98.md b/docs/manual-qa/NEO-98.md index cc82bf4..77f8fa0 100644 --- a/docs/manual-qa/NEO-98.md +++ b/docs/manual-qa/NEO-98.md @@ -17,11 +17,11 @@ ## Archetype reference -| Archetype | Instance id | Marker | maxHp | Pulses to defeat | NPC damage | Windup | Cooldown | -|-----------|-------------|--------|-------|------------------|------------|--------|----------| -| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | 1.5 s | 3.0 s | -| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 2.0 s | 4.0 s | -| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | 2.5 s | 5.0 s | +| Archetype | Instance id | Marker | maxHp | Pulses to defeat | NPC damage | Strike range | Windup | Cooldown | +|-----------|-------------|--------|-------|------------------|------------|--------------|--------|----------| +| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | **1 m** (close) | 1.5 s | 3.0 s | +| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 10 m | 2.0 s | 4.0 s | +| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | 8 m | 2.5 s | 5.0 s | Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). @@ -49,7 +49,7 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). 5. Press **1** (damaging cast): **`CastFeedbackLabel`** shows damage; **`NpcStateLabel`** eventually shows **`NPC state: Melee → aggro`** (~1 Hz poll). 6. **`PlayerCombatHpLabel`** shows **`Player HP: 100/100`** once combat poll starts. 7. Wait **≥ 3 s** from first cast: **`NpcStateLabel`** shows **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (e.g. **`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. -8. Wait **≥ 4.5 s** total from first cast (windup completes): **`PlayerCombatHpLabel`** drops to **`85/100`** (melee **15** damage). +8. Wait **≥ 4.5 s** total from first cast (windup completes): stay **within ~1 m** of the melee marker (close combat — tab-lock is 6 m but strike range is **1 m**). **`PlayerCombatHpLabel`** drops to **`85/100`** (melee **15** damage). Back off during windup → telegraph may finish but **no** player damage (whiff). 9. Press **1** three more times (respect ~3 s cooldown) until **`CombatTargetHpLabel`** shows **`0/100 (defeated)`** per defeat table above. 10. Press **1** again: **`ability_cast_denied: target_defeated`**. From 44fb4adbe5324a95a8f34c8007da9b05643a4713 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 17:55:10 -0400 Subject: [PATCH 09/13] NEO-98: Set melee strike to 2 m and elite slam to 4 m. --- content/abilities/prototype_npc_abilities.json | 4 ++-- docs/manual-qa/NEO-98.md | 6 +++--- .../Game/Combat/AbilityDefinitionCatalogLoaderTests.cs | 4 ++-- .../Game/Combat/AbilityDefinitionsWorldApiTests.cs | 2 +- .../Game/Npc/PrototypeNpcTestFixtures.cs | 4 ++-- server/README.md | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/content/abilities/prototype_npc_abilities.json b/content/abilities/prototype_npc_abilities.json index 68fe760..50e1d08 100644 --- a/content/abilities/prototype_npc_abilities.json +++ b/content/abilities/prototype_npc_abilities.json @@ -5,7 +5,7 @@ "id": "prototype_npc_melee_strike", "displayName": "NPC Melee Strike", "baseDamage": 15, - "maxRange": 1.0, + "maxRange": 2.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, @@ -21,7 +21,7 @@ "id": "prototype_npc_elite_slam", "displayName": "NPC Elite Slam", "baseDamage": 25, - "maxRange": 8.0, + "maxRange": 4.0, "cooldownSeconds": 5.0, "abilityKind": "attack" } diff --git a/docs/manual-qa/NEO-98.md b/docs/manual-qa/NEO-98.md index 77f8fa0..b1a128e 100644 --- a/docs/manual-qa/NEO-98.md +++ b/docs/manual-qa/NEO-98.md @@ -19,9 +19,9 @@ | Archetype | Instance id | Marker | maxHp | Pulses to defeat | NPC damage | Strike range | Windup | Cooldown | |-----------|-------------|--------|-------|------------------|------------|--------------|--------|----------| -| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | **1 m** (close) | 1.5 s | 3.0 s | +| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | **2 m** (close) | 1.5 s | 3.0 s | | Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 10 m | 2.0 s | 4.0 s | -| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | 8 m | 2.5 s | 5.0 s | +| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | **4 m** | 2.5 s | 5.0 s | Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). @@ -49,7 +49,7 @@ Player casts **`prototype_pulse`** (**25** damage, ~**3 s** cooldown). 5. Press **1** (damaging cast): **`CastFeedbackLabel`** shows damage; **`NpcStateLabel`** eventually shows **`NPC state: Melee → aggro`** (~1 Hz poll). 6. **`PlayerCombatHpLabel`** shows **`Player HP: 100/100`** once combat poll starts. 7. Wait **≥ 3 s** from first cast: **`NpcStateLabel`** shows **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (e.g. **`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. -8. Wait **≥ 4.5 s** total from first cast (windup completes): stay **within ~1 m** of the melee marker (close combat — tab-lock is 6 m but strike range is **1 m**). **`PlayerCombatHpLabel`** drops to **`85/100`** (melee **15** damage). Back off during windup → telegraph may finish but **no** player damage (whiff). +8. Wait **≥ 4.5 s** total from first cast (windup completes): stay **within ~2 m** of the melee marker (close combat — tab-lock is 6 m but strike range is **2 m**). **`PlayerCombatHpLabel`** drops to **`85/100`** (melee **15** damage). Back off during windup → telegraph may finish but **no** player damage (whiff). 9. Press **1** three more times (respect ~3 s cooldown) until **`CombatTargetHpLabel`** shows **`0/100 (defeated)`** per defeat table above. 10. Press **1** again: **`ability_cast_denied: target_defeated`**. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs index 6c56055..9dd7567 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs @@ -62,7 +62,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_npc_melee_strike", "displayName": "NPC Melee Strike", "baseDamage": 15, - "maxRange": 1.0, + "maxRange": 2.0, "cooldownSeconds": 3.0, "abilityKind": "attack" }, @@ -78,7 +78,7 @@ public class AbilityDefinitionCatalogLoaderTests "id": "prototype_npc_elite_slam", "displayName": "NPC Elite Slam", "baseDamage": 25, - "maxRange": 8.0, + "maxRange": 4.0, "cooldownSeconds": 5.0, "abilityKind": "attack" } diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs index 89073a6..3662ba2 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionsWorldApiTests.cs @@ -66,6 +66,6 @@ public class AbilityDefinitionsWorldApiTests var meleeStrike = body.Abilities.Single(a => a.Id == "prototype_npc_melee_strike"); Assert.Equal(15, meleeStrike.BaseDamage); - Assert.Equal(1.0, meleeStrike.MaxRange); + Assert.Equal(2.0, meleeStrike.MaxRange); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs index 186a5e2..9fd4bec 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs @@ -44,7 +44,7 @@ public static class PrototypeNpcTestFixtures "NPC Melee Strike", 15, 3.0, - 1.0, + 2.0, "attack"), [PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot] = new( PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot, @@ -58,7 +58,7 @@ public static class PrototypeNpcTestFixtures "NPC Elite Slam", 25, 5.0, - 8.0, + 4.0, "attack"), }; diff --git a/server/README.md b/server/README.md index 68e7f1b..1d6c5cf 100644 --- a/server/README.md +++ b/server/README.md @@ -161,7 +161,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In | Rule | Behavior | |------|----------| | **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. | -| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **1 m**, ranged shot **10 m**, elite slam **8 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | +| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **2 m**, ranged shot **10 m**, elite slam **4 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). | | **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. | | **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). | | **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. | From bc1357cf94d283080bf28709fa029e22f9e95fa2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 18:01:52 -0400 Subject: [PATCH 10/13] NEO-98: add follow-up code review for capstone integration fixes --- docs/reviews/2026-05-30-NEO-98-followup.md | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/reviews/2026-05-30-NEO-98-followup.md diff --git a/docs/reviews/2026-05-30-NEO-98-followup.md b/docs/reviews/2026-05-30-NEO-98-followup.md new file mode 100644 index 0000000..cad42f6 --- /dev/null +++ b/docs/reviews/2026-05-30-NEO-98-followup.md @@ -0,0 +1,67 @@ +# Code review — NEO-98 follow-up (capstone QA integration fixes) + +**Date:** 2026-05-30 +**Scope:** Branch `NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone` vs `origin/main` — commits `b03128a` … `44fb4ad` (delta since [2026-05-30-NEO-98.md](2026-05-30-NEO-98.md) at `817ed26`) +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +Since the first review, NEO-98 expanded beyond docs-only into **capstone-driven server integration**: defeated NPCs no longer keep attacking (`TryStopOnTargetDefeat` on cast accept + defeated guard in `AdvanceOne`), and telegraph damage is **range-gated** via new NPC attack abilities (`content/abilities/prototype_npc_abilities.json`, behavior **`attackAbilityId`**, `NpcAttackOperations` horizontal **`maxRange`** check). Content/schema/CI gates cross-reference behavior **`attackDamage`** against ability **`baseDamage`**. Manual QA and README now document melee **2 m** strike range and the whiff case. First-review doc nits (Esc regression, backlog AC parity, label format) are addressed. Server tests for defeat-stop, out-of-range whiff, and cast integration pass locally (**41** NpcRuntime/NpcAttack/stop-combat cases; **500** total in-memory tests). Residual risk is **documentation drift** on the E5.M2 module page (still describes NEO-95 resolve via flat **`attackDamage`**, freeze box omits **`attackAbilityId`** / strike ranges) and the plan **Out of scope** line that still forbids catalog changes. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-98-implementation-plan.md` | **Partially matches** — reconciliation documents defeat-stop fix; Files to modify lists server touch points; **Out of scope** still says “no behavior catalog changes” while diff adds NPC attack abilities + **`attackAbilityId`** (scope expansion not reflected in kickoff/out-of-scope). | +| `docs/manual-qa/NEO-98.md` | **Matches** — strike-range column, step 8 **2 m** proximity + whiff note, Esc regression (steps 19–21), full **`CombatTargetHpLabel`** format on step 4. | +| `client/README.md` | **Partially matches** — flow table uses full target HP label; melee hit row omits **stay within 2 m** note present in manual QA step 8. | +| `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Matches** — fourth AC (three-archetype defeat) present; landed note intact. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Conflicts** — NEO-95 bullet still says telegraph resolve applies catalog **`attackDamage`**; freeze table has no **`attackAbilityId`** or strike **`maxRange`**; NEO-98 capstone bullet is docs-only (no server integration note). **`server/README.md`** threat/runtime sections are authoritative for NEO-98 behavior changes. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E5.M2 **Ready** + NEO-98 capstone note; row does not mention defeat-clear or attack-ability range binding landed in NEO-98. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M2 note unchanged from docs batch; still cites NEO-98 capstone manual QA. | +| `server/README.md` | **Matches** — threat table documents **Attack range**, **Defeat clear**, and **`attackAbilityId`** resolve path. | +| `docs/manual-qa/NEO-97.md` | **Matches** — component regression still valid (elite at origin within **4 m** slam); no update required for capstone range work. | +| Full-stack epic decomposition | **Matches** — Godot capstone manual QA remains the prototype-complete proof; server fixes support playable QA without Bruno. | + +## Blocking issues + +None. + +## Suggestions + +1. **E5.M2 module doc — NEO-95 / NEO-98 server behavior** — Update the **Session player combat HP (NEO-95)** bullet and prototype freeze table to document **`attackAbilityId`**, NPC attack ability ids, and strike **`maxRange`** values (**2 / 10 / 4 m**). Note **`attackDamage`** on behavior rows is a projection/cross-ref field, not the resolve source after NEO-98. + +2. **Plan scope reconciliation** — Revise **Out of scope** (or add a **Decisions** row) to record capstone QA drove catalog + server changes: `prototype_npc_abilities.json`, behavior **`attackAbilityId`**, defeat-stop, range-gated resolve. Keeps kickoff “docs-primary” honest while documenting the adopted integration path. + +3. **`documentation_and_implementation_alignment.md` E5.M2 row** — Append a short **NEO-98 server integration** clause (defeat clears aggro/runtime; telegraph damage uses attack ability **`maxRange`**) alongside the existing capstone manual QA note. + +4. **`client/README.md` melee hit row** — Mirror manual QA step 8: tester must stay **within ~2 m** of the melee marker for **85/100**; backing off during windup whiffs. + +## Nits + +- Nit: Plan §1 melee spine prose still collapses telegraph + HP into a single **≥ 4.5 s** wait; manual QA correctly splits **≥ 3 s** (telegraph) and **≥ 4.5 s** (damage) — tighten plan technical approach for consistency. + +- Nit: `AdvanceAll` now requires **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`** — any future caller besides `NpcRuntimeSnapshotWorldApi` must pass the expanded signature (only one production caller today). + +- Nit: Whiff path advances NPC state to **`recover`** without damage — correct per server README; optional manual QA note for Ranged/Elite if testers back off during windup (not required for capstone AC). + +## Verification + +```bash +# NEO-98 server spine (in-memory; no Docker) +cd server/NeonSprawl.Server.Tests && dotnet test \ + --filter "FullyQualifiedName~NpcRuntime|FullyQualifiedName~NpcAttack|FullyQualifiedName~PostAbilityCast_ShouldStopNpcCombat" + +# Content cross-ref gates +python3 scripts/validate_content.py +``` + +**Local run:** 41/41 filtered tests passed; 500/511 full suite passed (11 Postgres/docker integration failures — environment infra, unrelated to this diff). + +**Manual (required before merge):** [`docs/manual-qa/NEO-98.md`](../manual-qa/NEO-98.md) — confirm step 8 (**85/100** only when within **2 m**), defeated melee does **not** keep damaging player after step 9, three-archetype defeat, Esc + NEO-28 deny (steps 19–20). + +**Optional:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) incoming-threat row regression. From b77b7f8fbb84e8324fbf429e4b77a853a8073512 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 18:02:55 -0400 Subject: [PATCH 11/13] NEO-98: Address follow-up review doc findings. Update E5.M2 module freeze, plan scope reconciliation, alignment register, client README melee row, and strike-through follow-up review. --- client/README.md | 2 +- .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 22 ++++++++----- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-98-implementation-plan.md | 31 ++++++++++++------- docs/reviews/2026-05-30-NEO-98-followup.md | 20 ++++++------ 5 files changed, 47 insertions(+), 30 deletions(-) diff --git a/client/README.md b/client/README.md index 01da5c4..d368a24 100644 --- a/client/README.md +++ b/client/README.md @@ -175,7 +175,7 @@ Epic 5 Slice 2 capstone — aggro, readable telegraph, incoming NPC damage, and | Melee lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** **`Target HP: prototype_npc_melee 100/100`** | | Melee aggro | **1** (damaging cast) | **`NpcStateLabel`** → **`aggro`**; combat poll ~1 Hz | | Melee telegraph | Wait **≥ 3 s** | **`telegraph_windup`** + **`TelegraphLabel`** countdown | -| Melee hit | Wait **≥ 4.5 s** from first cast | **`PlayerCombatHpLabel`** → **`85/100`** (15 damage) | +| Melee hit | Wait **≥ 4.5 s** from first cast; stay **within ~2 m** of melee marker | **`PlayerCombatHpLabel`** → **`85/100`** (15 damage); back off during windup → whiff | | Melee defeat | **1** ×4 total (cooldown between) | **`CombatTargetHpLabel`** includes **`0/100 (defeated)`** | | Ranged | Lock + cast ×4; wait **≥ 4 s** for telegraph | Defeat at **80** HP; telegraph visible before damage | | Elite | Lock + cast ×8; wait **≥ 5 s** for telegraph | Defeat at **200** HP; telegraph visible before damage | diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 194d066..1a040ab 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -67,11 +67,19 @@ Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M2-prototype- The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.json` is **frozen** for behavior binding, aggro tuning, and telegraph timings until a deliberate migration issue expands the roster. -| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` | -|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------| -| **`prototype_melee_pressure`** | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 | -| **`prototype_ranged_control`** | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 | -| **`prototype_elite_mini_boss`** | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 | +| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` | `attackAbilityId` | +|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|-------------------| +| **`prototype_melee_pressure`** | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 | **`prototype_npc_melee_strike`** | +| **`prototype_ranged_control`** | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 | **`prototype_npc_ranged_shot`** | +| **`prototype_elite_mini_boss`** | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 | **`prototype_npc_elite_slam`** | + +**NPC attack abilities (NEO-98):** [`content/abilities/prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) — telegraph resolve uses ability **`maxRange`** + **`baseDamage`**, not behavior **`aggroRadius`**. Behavior **`attackDamage`** remains on the HTTP projection and must match ability **`baseDamage`** (CI cross-ref). Strike **`maxRange`** (horizontal X/Z): melee **2 m**, ranged **10 m**, elite **4 m**. + +| Ability `id` | `baseDamage` | `maxRange` | +|--------------|--------------|------------| +| **`prototype_npc_melee_strike`** | 15 | 2.0 | +| **`prototype_npc_ranged_shot`** | 12 | 10.0 | +| **`prototype_npc_elite_slam`** | 25 | 4.0 | **NPC instance ids (E5M2-05):** **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** — each binds one behavior def + world anchor (replaces **`prototype_target_alpha` / `beta`**). @@ -89,13 +97,13 @@ 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/`. +**Session player combat HP (NEO-95 / NEO-98):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete looks up behavior **`attackAbilityId`**, applies ability catalog **`baseDamage`** when the aggro holder is within that ability's **`maxRange`** (horizontal X/Z); out-of-range windups whiff. Behavior row **`attackDamage`** is projection + CI cross-ref only (not the resolve source). **`TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard clears aggro/runtime when the NPC combat-target row is defeated (NEO-98). **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [NEO-98 implementation plan](../../plans/NEO-98-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 runtime telemetry hooks (NEO-96):** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** (`TODO(E9.M1)`; no ingest). Plan: [NEO-96 implementation plan](../../plans/NEO-96-implementation-plan.md); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **Client telegraph HUD (NEO-97):** Godot poll of **`npc-runtime-snapshot`** + **`combat-health`**; **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll gate. Plan: [NEO-97 implementation plan](../../plans/NEO-97-implementation-plan.md); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97); manual QA [NEO-97](../../manual-qa/NEO-97.md). -**Playable NPC telegraph combat capstone (NEO-98):** single-session Godot manual QA covering melee telegraph spine + three-archetype defeat — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). Plan: [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md). **Epic 5 Slice 2 client capstone complete.** +**Playable NPC telegraph combat capstone (NEO-98):** single-session Godot manual QA covering melee telegraph spine + three-archetype defeat — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). Plan: [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md). **Server integration (capstone QA):** [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json), behavior **`attackAbilityId`**, range-gated telegraph resolve, defeat clears aggro/runtime. **Epic 5 Slice 2 client capstone complete.** **NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 2b9e4e2..576eb48 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 | Ready | **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/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **Epic 5 Slice 2 client capstone complete.** **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) **landed**. 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), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | Ready | **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 resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-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/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **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) **landed**. 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), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/plans/NEO-98-implementation-plan.md b/docs/plans/NEO-98-implementation-plan.md index a5dc32e..0c5d6b2 100644 --- a/docs/plans/NEO-98-implementation-plan.md +++ b/docs/plans/NEO-98-implementation-plan.md @@ -23,6 +23,7 @@ | **Archetype order** | Order in single-session script? | **Melee → Ranged → Elite** — matches E5M2-12 backlog goal prose (“aggro a melee NPC… defeat all three archetypes”). | **User:** melee → ranged → elite. | | **Verification depth** | Full telegraph spine per archetype? | **Full telegraph + player HP on Melee**; Ranged + Elite **defeat + telegraph visible** with documented windup/cooldown — keeps one-session script tractable while satisfying Slice 2 AC. | **User:** melee full; ranged/elite light. | | **NEO-97 relationship** | Supersede component QA? | **NEO-98 supersedes as capstone**; keep [NEO-97](../manual-qa/NEO-97.md) as component-level regression reference (NEO-85 / NEO-86 pattern). | **Adopted** (repo precedent; not asked). | +| **Capstone QA scope expansion** | Server/catalog changes allowed? | Kickoff was docs-primary; capstone QA may drive minimal integration fixes — document any catalog or server touch in plan reconciliation. | **Adopted:** defeat-stop, **`prototype_npc_abilities.json`**, behavior **`attackAbilityId`**, range-gated telegraph resolve (see **Implementation reconciliation**). | ## Goal, scope, and out-of-scope @@ -40,7 +41,9 @@ - Encounters/loot ([E5.M3](../decomposition/modules/E5_M3_EncounterAndRewardTables.md)), spawn ecology ([E4.M2](../decomposition/modules/E4_M2_SpawnEcologyController.md)). - Final VFX art, dodge mechanics, player death flow. - Bruno-only verification as prototype-complete proof. -- New server APIs or behavior catalog changes. +- New server HTTP routes beyond existing cast/snapshot surfaces; new NPC archetypes beyond the frozen three. + +**Originally out of scope at kickoff:** behavior/ability catalog changes and server integration beyond docs — **expanded during capstone QA** (see kickoff **Capstone QA scope expansion** and **Implementation reconciliation**). ## Acceptance criteria checklist @@ -55,6 +58,7 @@ - **`client/README.md`** — **End-to-end NPC telegraph combat loop (NEO-98)** section with flow table and cross-links. - **`docs/plans/E5M2-prototype-backlog.md`**, **`E5_M2_NpcAiAndBehaviorProfiles.md`**, **`documentation_and_implementation_alignment.md`** — E5M2-12 landed; E5.M2 **Ready**; Epic 5 Slice 2 client capstone complete. - **Integration fix (capstone QA):** defeated NPCs continued attacking — **`NpcRuntimeOperations.TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard; server tests added. +- **Integration fix (capstone QA):** telegraph damage at aggro radius — **`prototype_npc_abilities.json`** (three NPC attack abilities), behavior **`attackAbilityId`**, **`AbilityDef.maxRange`** on resolve; melee strike **2 m**, ranged **10 m**, elite **4 m**; CI cross-ref **`attackDamage`** ↔ ability **`baseDamage`**. ## Technical approach @@ -64,18 +68,18 @@ **Frozen catalog math** (from [E5_M2 freeze box](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01); player casts **`prototype_pulse`** **25** dmg, ~**3 s** cooldown): -| Archetype | Instance id | Marker | maxHp | Casts to defeat | attackDamage | windup | cooldown | -|-----------|-------------|--------|-------|-----------------|--------------|--------|----------| -| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | 1.5 s | 3.0 s | -| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 2.0 s | 4.0 s | -| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | 2.5 s | 5.0 s | +| Archetype | Instance id | Marker | maxHp | Casts to defeat | attackDamage | strike range | windup | cooldown | +|-----------|-------------|--------|-------|-----------------|--------------|--------------|--------|----------| +| Melee | `prototype_npc_melee` | orange west **`(-3, -3)`** | 100 | **4** | 15 | **2 m** | 1.5 s | 3.0 s | +| Ranged | `prototype_npc_ranged` | purple SE **`(3, 3)`** | 80 | **4** | 12 | 10 m | 2.0 s | 4.0 s | +| Elite | `prototype_npc_elite` | gold origin **`(0, 0)`** | 200 | **8** | 25 | **4 m** | 2.5 s | 5.0 s | **Melee full spine (telegraph + player HP):** 1. Walk to **Melee** marker; **Tab** lock **`prototype_npc_melee`** — **`Validity: ok`**. 2. Press **1** (damaging cast): **`NpcStateLabel`** → **`aggro`** (~1 Hz poll); **`PlayerCombatHpLabel`** → **`100/100`**. -3. Wait **≥ 4.5 s** from first cast (3.0 s cooldown + 1.5 s windup): **`NpcStateLabel`** → **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (**`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. -4. After windup completes: **`PlayerCombatHpLabel`** → **`85/100`** (15 damage). +3. Wait **≥ 3 s** from first cast (cooldown elapses): **`NpcStateLabel`** → **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (**`Telegraph: Melee … · ~1.x s (melee)`**) ticking between polls. +4. Wait **≥ 4.5 s** total from first cast (windup completes): stay **within ~2 m** of the melee marker; **`PlayerCombatHpLabel`** → **`85/100`** (15 damage). Back off during windup → whiff (no damage). 5. Press **1** three more times (respect cooldown) until **`CombatTargetHpLabel`** → **`0/100 (defeated)`** and cast feedback shows defeat. **Ranged light (defeat + telegraph visible):** @@ -138,10 +142,15 @@ Run capstone dry-run on **`main`** before doc-only work; if any step fails, fix | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — NEO-98 landed + Slice 2 client capstone complete (on story completion). | | `docs/plans/E5M2-prototype-backlog.md` | E5M2-12 acceptance checkboxes + landed note (on story completion). | | `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | E5M2-12 capstone landed note + manual QA link (on story completion). | -| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | **`TryStopOnTargetDefeat`** + defeated guard in **`AdvanceOne`**; **`AdvanceAll`** takes **`ICombatEntityHealthStore`**. | -| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | Pass combat health store into **`AdvanceAll`**. | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs` | **`TryStopOnTargetDefeat`** + defeated guard in **`AdvanceOne`**; **`AdvanceAll`** takes **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`**. | +| `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | Pass ability registry + combat health store into **`AdvanceAll`**. | +| `server/NeonSprawl.Server/Game/Combat/NpcAttackOperations.cs` | Range-gated resolve via behavior **`attackAbilityId`** + ability **`maxRange`**. | | `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Stop NPC combat on **`TargetDefeated`**. | -| `server/README.md` | Threat table — **Defeat clear** rule. | +| `content/abilities/prototype_npc_abilities.json` | Three NPC attack abilities + strike **`maxRange`**. | +| `content/npc-behaviors/prototype_npc_behaviors.json` | **`attackAbilityId`** per archetype. | +| `content/schemas/ability-def.schema.json`, `npc-behavior-def.schema.json` | **`maxRange`**, **`attackAbilityId`**. | +| `scripts/validate_content.py` | Seven-id ability gate + behavior ↔ ability cross-ref. | +| `server/README.md` | Threat table — **Attack range**, **Defeat clear**, **`attackAbilityId`** resolve path. | **Conditional (only if capstone QA fails on client):** diff --git a/docs/reviews/2026-05-30-NEO-98-followup.md b/docs/reviews/2026-05-30-NEO-98-followup.md index cad42f6..8caec63 100644 --- a/docs/reviews/2026-05-30-NEO-98-followup.md +++ b/docs/reviews/2026-05-30-NEO-98-followup.md @@ -10,18 +10,18 @@ ## Summary -Since the first review, NEO-98 expanded beyond docs-only into **capstone-driven server integration**: defeated NPCs no longer keep attacking (`TryStopOnTargetDefeat` on cast accept + defeated guard in `AdvanceOne`), and telegraph damage is **range-gated** via new NPC attack abilities (`content/abilities/prototype_npc_abilities.json`, behavior **`attackAbilityId`**, `NpcAttackOperations` horizontal **`maxRange`** check). Content/schema/CI gates cross-reference behavior **`attackDamage`** against ability **`baseDamage`**. Manual QA and README now document melee **2 m** strike range and the whiff case. First-review doc nits (Esc regression, backlog AC parity, label format) are addressed. Server tests for defeat-stop, out-of-range whiff, and cast integration pass locally (**41** NpcRuntime/NpcAttack/stop-combat cases; **500** total in-memory tests). Residual risk is **documentation drift** on the E5.M2 module page (still describes NEO-95 resolve via flat **`attackDamage`**, freeze box omits **`attackAbilityId`** / strike ranges) and the plan **Out of scope** line that still forbids catalog changes. +Since the first review, NEO-98 expanded beyond docs-only into **capstone-driven server integration**: defeated NPCs no longer keep attacking (`TryStopOnTargetDefeat` on cast accept + defeated guard in `AdvanceOne`), and telegraph damage is **range-gated** via new NPC attack abilities (`content/abilities/prototype_npc_abilities.json`, behavior **`attackAbilityId`**, `NpcAttackOperations` horizontal **`maxRange`** check). Content/schema/CI gates cross-reference behavior **`attackDamage`** against ability **`baseDamage`**. Manual QA and README document melee **2 m** strike range and the whiff case. Follow-up doc suggestions (E5.M2 module freeze, plan scope reconciliation, alignment register, client README melee row, plan timing split) are addressed in a docs batch after this review. ## Documentation checked | Path | Result | |------|--------| -| `docs/plans/NEO-98-implementation-plan.md` | **Partially matches** — reconciliation documents defeat-stop fix; Files to modify lists server touch points; **Out of scope** still says “no behavior catalog changes” while diff adds NPC attack abilities + **`attackAbilityId`** (scope expansion not reflected in kickoff/out-of-scope). | +| `docs/plans/NEO-98-implementation-plan.md` | **Matches** — kickoff **Capstone QA scope expansion**; revised **Out of scope**; reconciliation + files list cover defeat-stop and attack-ability range binding; melee spine splits **≥ 3 s** telegraph / **≥ 4.5 s** damage. | | `docs/manual-qa/NEO-98.md` | **Matches** — strike-range column, step 8 **2 m** proximity + whiff note, Esc regression (steps 19–21), full **`CombatTargetHpLabel`** format on step 4. | -| `client/README.md` | **Partially matches** — flow table uses full target HP label; melee hit row omits **stay within 2 m** note present in manual QA step 8. | +| `client/README.md` | **Matches** — flow table uses full target HP label; **Melee hit** row documents **within ~2 m** + whiff. | | `docs/plans/E5M2-prototype-backlog.md` (E5M2-12) | **Matches** — fourth AC (three-archetype defeat) present; landed note intact. | -| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Conflicts** — NEO-95 bullet still says telegraph resolve applies catalog **`attackDamage`**; freeze table has no **`attackAbilityId`** or strike **`maxRange`**; NEO-98 capstone bullet is docs-only (no server integration note). **`server/README.md`** threat/runtime sections are authoritative for NEO-98 behavior changes. | -| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Partially matches** — E5.M2 **Ready** + NEO-98 capstone note; row does not mention defeat-clear or attack-ability range binding landed in NEO-98. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — freeze table includes **`attackAbilityId`** + NPC attack ability strike ranges; NEO-95/NEO-98 bullets document range-gated resolve and defeat-clear. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 **Ready** + NEO-98 capstone + server integration clause. | | `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M2 note unchanged from docs batch; still cites NEO-98 capstone manual QA. | | `server/README.md` | **Matches** — threat table documents **Attack range**, **Defeat clear**, and **`attackAbilityId`** resolve path. | | `docs/manual-qa/NEO-97.md` | **Matches** — component regression still valid (elite at origin within **4 m** slam); no update required for capstone range work. | @@ -33,17 +33,17 @@ None. ## Suggestions -1. **E5.M2 module doc — NEO-95 / NEO-98 server behavior** — Update the **Session player combat HP (NEO-95)** bullet and prototype freeze table to document **`attackAbilityId`**, NPC attack ability ids, and strike **`maxRange`** values (**2 / 10 / 4 m**). Note **`attackDamage`** on behavior rows is a projection/cross-ref field, not the resolve source after NEO-98. +1. ~~**E5.M2 module doc — NEO-95 / NEO-98 server behavior** — Update the **Session player combat HP (NEO-95)** bullet and prototype freeze table to document **`attackAbilityId`**, NPC attack ability ids, and strike **`maxRange`** values (**2 / 10 / 4 m**). Note **`attackDamage`** on behavior rows is a projection/cross-ref field, not the resolve source after NEO-98.~~ **Done.** [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) freeze table + NPC attack ability table; NEO-95/NEO-98 bullets updated. -2. **Plan scope reconciliation** — Revise **Out of scope** (or add a **Decisions** row) to record capstone QA drove catalog + server changes: `prototype_npc_abilities.json`, behavior **`attackAbilityId`**, defeat-stop, range-gated resolve. Keeps kickoff “docs-primary” honest while documenting the adopted integration path. +2. ~~**Plan scope reconciliation** — Revise **Out of scope** (or add a **Decisions** row) to record capstone QA drove catalog + server changes: `prototype_npc_abilities.json`, behavior **`attackAbilityId`**, defeat-stop, range-gated resolve. Keeps kickoff “docs-primary” honest while documenting the adopted integration path.~~ **Done.** Kickoff **Capstone QA scope expansion** row + revised **Out of scope** / reconciliation in [`NEO-98-implementation-plan.md`](../plans/NEO-98-implementation-plan.md). -3. **`documentation_and_implementation_alignment.md` E5.M2 row** — Append a short **NEO-98 server integration** clause (defeat clears aggro/runtime; telegraph damage uses attack ability **`maxRange`**) alongside the existing capstone manual QA note. +3. ~~**`documentation_and_implementation_alignment.md` E5.M2 row** — Append a short **NEO-98 server integration** clause (defeat clears aggro/runtime; telegraph damage uses attack ability **`maxRange`**) alongside the existing capstone manual QA note.~~ **Done.** NEO-95 + NEO-98 clauses updated in alignment register. -4. **`client/README.md` melee hit row** — Mirror manual QA step 8: tester must stay **within ~2 m** of the melee marker for **85/100**; backing off during windup whiffs. +4. ~~**`client/README.md` melee hit row** — Mirror manual QA step 8: tester must stay **within ~2 m** of the melee marker for **85/100**; backing off during windup whiffs.~~ **Done.** Flow table **Melee hit** row updated. ## Nits -- Nit: Plan §1 melee spine prose still collapses telegraph + HP into a single **≥ 4.5 s** wait; manual QA correctly splits **≥ 3 s** (telegraph) and **≥ 4.5 s** (damage) — tighten plan technical approach for consistency. +- ~~Nit: Plan §1 melee spine prose still collapses telegraph + HP into a single **≥ 4.5 s** wait; manual QA correctly splits **≥ 3 s** (telegraph) and **≥ 4.5 s** (damage) — tighten plan technical approach for consistency.~~ **Done.** Plan melee spine steps 3–4 split timing + **2 m** whiff note. - Nit: `AdvanceAll` now requires **`IAbilityDefinitionRegistry`** + **`ICombatEntityHealthStore`** — any future caller besides `NpcRuntimeSnapshotWorldApi` must pass the expanded signature (only one production caller today). From 04126693e1dab932e3c9300de1b9765046032fc1 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 18:06:42 -0400 Subject: [PATCH 12/13] NEO-98: Update Bruno for seven abilities and attackAbilityId. --- .../Get ability definitions.bru | 41 ++++++++++++++++++- .../Get npc behavior definitions.bru | 17 ++++++++ .../NpcBehaviorDefinitionsWorldApiTests.cs | 2 + 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru b/bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru index f851f56..dd6ccb1 100644 --- a/bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru +++ b/bruno/neon-sprawl-server/ability-definitions/Get ability definitions.bru @@ -17,7 +17,7 @@ tests { const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.abilities).to.be.an("array"); - expect(body.abilities.length).to.equal(4); + expect(body.abilities.length).to.equal(7); }); test("abilities are ascending by id (ordinal)", function () { @@ -27,13 +27,16 @@ tests { expect(ids).to.eql(sorted); }); - test("frozen prototype four matches registry id order", function () { + test("frozen prototype seven matches registry id order", function () { const body = res.getBody(); const ids = body.abilities.map((x) => x.id); expect(ids).to.eql([ "prototype_burst", "prototype_dash", "prototype_guard", + "prototype_npc_elite_slam", + "prototype_npc_melee_strike", + "prototype_npc_ranged_shot", "prototype_pulse", ]); }); @@ -44,6 +47,7 @@ tests { expect(row).to.be.an("object"); expect(row.displayName).to.equal("Prototype Pulse"); expect(row.baseDamage).to.equal(25); + expect(row.maxRange).to.equal(6); expect(row.cooldownSeconds).to.equal(3); expect(row.abilityKind).to.equal("attack"); }); @@ -54,6 +58,7 @@ tests { expect(row).to.be.an("object"); expect(row.displayName).to.equal("Prototype Burst"); expect(row.baseDamage).to.equal(40); + expect(row.maxRange).to.equal(6); expect(row.cooldownSeconds).to.equal(5); expect(row.abilityKind).to.equal("attack"); }); @@ -64,6 +69,7 @@ tests { expect(row).to.be.an("object"); expect(row.abilityKind).to.equal("utility"); expect(row.baseDamage).to.equal(0); + expect(row.maxRange).to.equal(6); expect(row.cooldownSeconds).to.equal(6); }); @@ -73,6 +79,37 @@ tests { expect(row).to.be.an("object"); expect(row.abilityKind).to.equal("movement"); expect(row.baseDamage).to.equal(0); + expect(row.maxRange).to.equal(6); expect(row.cooldownSeconds).to.equal(4); }); + + test("prototype_npc_melee_strike row matches catalog", function () { + const body = res.getBody(); + const row = body.abilities.find((x) => x.id === "prototype_npc_melee_strike"); + expect(row).to.be.an("object"); + expect(row.baseDamage).to.equal(15); + expect(row.maxRange).to.equal(2); + expect(row.cooldownSeconds).to.equal(3); + expect(row.abilityKind).to.equal("attack"); + }); + + test("prototype_npc_ranged_shot row matches catalog", function () { + const body = res.getBody(); + const row = body.abilities.find((x) => x.id === "prototype_npc_ranged_shot"); + expect(row).to.be.an("object"); + expect(row.baseDamage).to.equal(12); + expect(row.maxRange).to.equal(10); + expect(row.cooldownSeconds).to.equal(4); + expect(row.abilityKind).to.equal("attack"); + }); + + test("prototype_npc_elite_slam row matches catalog", function () { + const body = res.getBody(); + const row = body.abilities.find((x) => x.id === "prototype_npc_elite_slam"); + expect(row).to.be.an("object"); + expect(row.baseDamage).to.equal(25); + expect(row.maxRange).to.equal(4); + expect(row.cooldownSeconds).to.equal(5); + expect(row.abilityKind).to.equal("attack"); + }); } diff --git a/bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru b/bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru index 0358b1e..60e7c74 100644 --- a/bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru +++ b/bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru @@ -49,6 +49,22 @@ tests { expect(row.telegraphWindupSeconds).to.equal(1.5); expect(row.attackDamage).to.equal(15); expect(row.attackCooldownSeconds).to.equal(3); + expect(row.attackAbilityId).to.equal("prototype_npc_melee_strike"); + }); + + test("prototype_ranged_control row matches catalog", function () { + const body = res.getBody(); + const row = body.npcBehaviors.find((x) => x.id === "prototype_ranged_control"); + expect(row).to.be.an("object"); + expect(row.displayName).to.equal("Ranged Control"); + expect(row.archetypeKind).to.equal("ranged_control"); + expect(row.maxHp).to.equal(80); + expect(row.aggroRadius).to.equal(10); + expect(row.leashRadius).to.equal(20); + expect(row.telegraphWindupSeconds).to.equal(2); + expect(row.attackDamage).to.equal(12); + expect(row.attackCooldownSeconds).to.equal(4); + expect(row.attackAbilityId).to.equal("prototype_npc_ranged_shot"); }); test("prototype_elite_mini_boss row matches catalog", function () { @@ -63,5 +79,6 @@ tests { expect(row.telegraphWindupSeconds).to.equal(2.5); expect(row.attackDamage).to.equal(25); expect(row.attackCooldownSeconds).to.equal(5); + expect(row.attackAbilityId).to.equal("prototype_npc_elite_slam"); }); } diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionsWorldApiTests.cs index 1b8b01c..142bda1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionsWorldApiTests.cs @@ -43,6 +43,7 @@ public class NpcBehaviorDefinitionsWorldApiTests Assert.Equal(1.5, melee.TelegraphWindupSeconds); Assert.Equal(15, melee.AttackDamage); Assert.Equal(3.0, melee.AttackCooldownSeconds); + Assert.Equal("prototype_npc_melee_strike", melee.AttackAbilityId); var elite = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss); Assert.Equal("Elite Mini-Boss", elite.DisplayName); @@ -53,5 +54,6 @@ public class NpcBehaviorDefinitionsWorldApiTests Assert.Equal(2.5, elite.TelegraphWindupSeconds); Assert.Equal(25, elite.AttackDamage); Assert.Equal(5.0, elite.AttackCooldownSeconds); + Assert.Equal("prototype_npc_elite_slam", elite.AttackAbilityId); } } From e6b8386102361ec6ee077cb94f0e923414508044 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 18:29:30 -0400 Subject: [PATCH 13/13] NEO-98: Remove unused ability gate alias in validate_content. --- scripts/validate_content.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/validate_content.py b/scripts/validate_content.py index edeb641..ceff4b0 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -495,11 +495,6 @@ def _prototype_ability_catalog_gate(seen_ids: dict[str, str]) -> str | None: return None -def _prototype_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None: - """Backward-compatible alias for the combined prototype ability catalog gate.""" - return _prototype_ability_catalog_gate(seen_ids) - - def _validate_npc_behavior_catalogs( *, npc_behavior_files: list[Path],