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/client/README.md b/client/README.md index 522e64d..d368a24 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`** **`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; 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 | + +**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/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..50e1d08 --- /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": 2.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": 4.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/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index a241f37..1a040ab 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -7,8 +7,8 @@ | **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) | -| **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) | +| **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-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 @@ -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,12 +97,14 @@ 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). **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/`. ## 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..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 | 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 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/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..b1a128e --- /dev/null +++ b/docs/manual-qa/NEO-98.md @@ -0,0 +1,88 @@ +# 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). +- **`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 + +| 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 | **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 | **4 m** | 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 **`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. +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`**. + +### 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. 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 + +- 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–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 937a6f7..09c77bb 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -375,9 +375,12 @@ 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] 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.** **Client counterpart:** this issue (**NEO-98**). diff --git a/docs/plans/NEO-98-implementation-plan.md b/docs/plans/NEO-98-implementation-plan.md new file mode 100644 index 0000000..0c5d6b2 --- /dev/null +++ b/docs/plans/NEO-98-implementation-plan.md @@ -0,0 +1,181 @@ +# 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). | +| **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 + +**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 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 + +- [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 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 + +### 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 | 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 **≥ 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):** + +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). | +| `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`**. | +| `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):** + +| 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 | +|------|--------| +| `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). + +**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`** | 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** | +| **NEO-97 merge dependency** | **NEO-97 merged** to `main` (PR #136) — capstone can proceed. | **adopted** | 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..8caec63 --- /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 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` | **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` | **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` | **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. | +| 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.~~ **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.~~ **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.~~ **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.~~ **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.~~ **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). + +- 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. 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..ae7bb23 --- /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) | **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. | +| `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.~~ **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.~~ **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.~~ **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).~~ **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. + +## 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–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. diff --git a/scripts/validate_content.py b/scripts/validate_content.py index b56254b..ceff4b0 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,17 +479,18 @@ 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_e5m1_ability_gate(seen_ids: dict[str, str]) -> str | None: - """Return a human-readable error if E5M1 ability contract fails, else None.""" +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_E5M1_ABILITY_IDS: + if ids != PROTOTYPE_ABILITY_CATALOG_IDS: return ( - "error: prototype E5M1 expects exactly ability ids " - f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}" + "error: prototype ability catalog expects exactly ability ids " + f"{sorted(PROTOTYPE_ABILITY_CATALOG_IDS)!r}, got {sorted(ids)!r}" ) return None @@ -555,6 +568,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 +1198,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 +1206,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 +1229,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/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/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs index 74041ee..9dd7567 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": 2.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": 4.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..3662ba2 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(2.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/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); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 572eabb..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,13 +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, 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()); } @@ -27,6 +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( @@ -34,6 +49,9 @@ public sealed class NpcRuntimeOperationsTests runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, + combatHealthStore, + positionStore, playerHealthStore, maxDeltaSeconds); @@ -41,9 +59,9 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); // Act - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); @@ -54,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); @@ -69,10 +87,10 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); runtime.LastAdvancedUtc = T0; // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, 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); @@ -83,11 +101,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(3), runtime, threat, behavior, 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); @@ -99,11 +117,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(2.9), runtime, threat, behavior, 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); @@ -113,12 +131,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, abilities, combatHealth, positions, playerHealth); + Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(4.4), runtime, threat, behavior, 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); @@ -128,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, 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, 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); @@ -146,13 +164,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, 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, 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); @@ -164,11 +182,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(7.5), runtime, threat, behavior, 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); @@ -180,13 +198,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, 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, 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); @@ -201,11 +219,11 @@ public sealed class NpcRuntimeOperationsTests double attackCooldownSeconds) { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, 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); @@ -221,12 +239,12 @@ public sealed class NpcRuntimeOperationsTests double telegraphWindupSeconds) { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); - Advance(T0, runtime, threat, behavior, playerHealth); - Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, 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, 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); @@ -239,11 +257,11 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, playerHealth); + Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Act - Advance(T0.AddSeconds(10), runtime, threat, behavior, 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); @@ -255,12 +273,12 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, 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, 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); @@ -272,14 +290,14 @@ public sealed class NpcRuntimeOperationsTests public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); - Advance(T0, runtime, threat, behavior, 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, 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); @@ -291,11 +309,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, 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, playerHealth); + Advance(T0.AddSeconds(5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); } @@ -304,13 +322,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, 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, 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); @@ -320,13 +338,13 @@ public sealed class NpcRuntimeOperationsTests public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() { // Arrange - var (runtime, threat, behavior, playerHealth) = CreateFixture(); + var (runtime, threat, behavior, abilities, combatHealth, positions, 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, 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, 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); @@ -347,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 @@ -359,4 +378,78 @@ public sealed class NpcRuntimeOperationsTests Assert.False(windupOk); Assert.True(validOk); } + + [Fact] + public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime() + { + // Arrange + var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + 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( + 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, abilities, combatHealth, positions, playerHealth) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + 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, + combatHealth, + threat, + runtime); + // Act + 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..9fd4bec 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, + 2.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, + 4.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/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/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 e138945..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,6 +21,9 @@ public static class NpcRuntimeOperations INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, + ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = DefaultMaxDeltaSeconds) { @@ -40,6 +44,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, + combatHealthStore, + positionStore, playerHealthStore); } @@ -59,6 +66,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, + combatHealthStore, + positionStore, playerHealthStore); } @@ -78,6 +88,9 @@ public static class NpcRuntimeOperations runtimeStore, threatStore, behaviorRegistry, + abilityRegistry, + combatHealthStore, + positionStore, playerHealthStore); } @@ -92,6 +105,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 +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) || @@ -117,6 +154,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) @@ -212,7 +262,9 @@ public static class NpcRuntimeOperations NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, - behavior.AttackDamage, + behavior.AttackAbilityId, + abilityRegistry, + positionStore, playerHealthStore, threatStore); WriteState( @@ -233,7 +285,9 @@ public static class NpcRuntimeOperations NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, - behavior.AttackDamage, + behavior.AttackAbilityId, + abilityRegistry, + positionStore, playerHealthStore, threatStore); WriteState( @@ -326,4 +380,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..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,6 +15,9 @@ public static class NpcRuntimeSnapshotWorldApi INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, + IAbilityDefinitionRegistry abilityRegistry, + ICombatEntityHealthStore combatHealthStore, + IPositionStateStore positionStore, IPlayerCombatHealthStore playerHealthStore) => { var now = clock.GetUtcNow(); @@ -22,6 +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 9fb9939..1d6c5cf 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,7 +161,9 @@ 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 **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. | | **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). | @@ -176,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 | @@ -208,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) @@ -217,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. |