Merge pull request #137 from ViPro-Technologies/NEO-98-e5m2-12-playable-npc-telegraph-combat-capstone

NEO-98: E5M2-12 playable NPC telegraph combat capstone
pull/138/head
VinPropane 2026-05-30 18:34:32 -04:00 committed by GitHub
commit 2f1b7f10be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1281 additions and 151 deletions

View File

@ -17,7 +17,7 @@ tests {
const body = res.getBody(); const body = res.getBody();
expect(body.schemaVersion).to.equal(1); expect(body.schemaVersion).to.equal(1);
expect(body.abilities).to.be.an("array"); 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 () { test("abilities are ascending by id (ordinal)", function () {
@ -27,13 +27,16 @@ tests {
expect(ids).to.eql(sorted); 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 body = res.getBody();
const ids = body.abilities.map((x) => x.id); const ids = body.abilities.map((x) => x.id);
expect(ids).to.eql([ expect(ids).to.eql([
"prototype_burst", "prototype_burst",
"prototype_dash", "prototype_dash",
"prototype_guard", "prototype_guard",
"prototype_npc_elite_slam",
"prototype_npc_melee_strike",
"prototype_npc_ranged_shot",
"prototype_pulse", "prototype_pulse",
]); ]);
}); });
@ -44,6 +47,7 @@ tests {
expect(row).to.be.an("object"); expect(row).to.be.an("object");
expect(row.displayName).to.equal("Prototype Pulse"); expect(row.displayName).to.equal("Prototype Pulse");
expect(row.baseDamage).to.equal(25); expect(row.baseDamage).to.equal(25);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(3); expect(row.cooldownSeconds).to.equal(3);
expect(row.abilityKind).to.equal("attack"); expect(row.abilityKind).to.equal("attack");
}); });
@ -54,6 +58,7 @@ tests {
expect(row).to.be.an("object"); expect(row).to.be.an("object");
expect(row.displayName).to.equal("Prototype Burst"); expect(row.displayName).to.equal("Prototype Burst");
expect(row.baseDamage).to.equal(40); expect(row.baseDamage).to.equal(40);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(5); expect(row.cooldownSeconds).to.equal(5);
expect(row.abilityKind).to.equal("attack"); expect(row.abilityKind).to.equal("attack");
}); });
@ -64,6 +69,7 @@ tests {
expect(row).to.be.an("object"); expect(row).to.be.an("object");
expect(row.abilityKind).to.equal("utility"); expect(row.abilityKind).to.equal("utility");
expect(row.baseDamage).to.equal(0); expect(row.baseDamage).to.equal(0);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(6); expect(row.cooldownSeconds).to.equal(6);
}); });
@ -73,6 +79,37 @@ tests {
expect(row).to.be.an("object"); expect(row).to.be.an("object");
expect(row.abilityKind).to.equal("movement"); expect(row.abilityKind).to.equal("movement");
expect(row.baseDamage).to.equal(0); expect(row.baseDamage).to.equal(0);
expect(row.maxRange).to.equal(6);
expect(row.cooldownSeconds).to.equal(4); 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");
});
} }

View File

@ -49,6 +49,22 @@ tests {
expect(row.telegraphWindupSeconds).to.equal(1.5); expect(row.telegraphWindupSeconds).to.equal(1.5);
expect(row.attackDamage).to.equal(15); expect(row.attackDamage).to.equal(15);
expect(row.attackCooldownSeconds).to.equal(3); 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 () { test("prototype_elite_mini_boss row matches catalog", function () {
@ -63,5 +79,6 @@ tests {
expect(row.telegraphWindupSeconds).to.equal(2.5); expect(row.telegraphWindupSeconds).to.equal(2.5);
expect(row.attackDamage).to.equal(25); expect(row.attackDamage).to.equal(25);
expect(row.attackCooldownSeconds).to.equal(5); expect(row.attackCooldownSeconds).to.equal(5);
expect(row.attackAbilityId).to.equal("prototype_npc_elite_slam");
}); });
} }

View File

@ -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). 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) ## 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. Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility **in Godot** without Bruno.

View File

@ -5,6 +5,7 @@
"id": "prototype_pulse", "id": "prototype_pulse",
"displayName": "Prototype Pulse", "displayName": "Prototype Pulse",
"baseDamage": 25, "baseDamage": 25,
"maxRange": 6.0,
"cooldownSeconds": 3.0, "cooldownSeconds": 3.0,
"abilityKind": "attack" "abilityKind": "attack"
}, },
@ -12,6 +13,7 @@
"id": "prototype_guard", "id": "prototype_guard",
"displayName": "Prototype Guard", "displayName": "Prototype Guard",
"baseDamage": 0, "baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 6.0, "cooldownSeconds": 6.0,
"abilityKind": "utility" "abilityKind": "utility"
}, },
@ -19,6 +21,7 @@
"id": "prototype_dash", "id": "prototype_dash",
"displayName": "Prototype Dash", "displayName": "Prototype Dash",
"baseDamage": 0, "baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 4.0, "cooldownSeconds": 4.0,
"abilityKind": "movement" "abilityKind": "movement"
}, },
@ -26,6 +29,7 @@
"id": "prototype_burst", "id": "prototype_burst",
"displayName": "Prototype Burst", "displayName": "Prototype Burst",
"baseDamage": 40, "baseDamage": 40,
"maxRange": 6.0,
"cooldownSeconds": 5.0, "cooldownSeconds": 5.0,
"abilityKind": "attack" "abilityKind": "attack"
} }

View File

@ -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"
}
]
}

View File

@ -10,7 +10,8 @@
"leashRadius": 16.0, "leashRadius": 16.0,
"telegraphWindupSeconds": 1.5, "telegraphWindupSeconds": 1.5,
"attackDamage": 15, "attackDamage": 15,
"attackCooldownSeconds": 3.0 "attackCooldownSeconds": 3.0,
"attackAbilityId": "prototype_npc_melee_strike"
}, },
{ {
"id": "prototype_ranged_control", "id": "prototype_ranged_control",
@ -21,7 +22,8 @@
"leashRadius": 20.0, "leashRadius": 20.0,
"telegraphWindupSeconds": 2.0, "telegraphWindupSeconds": 2.0,
"attackDamage": 12, "attackDamage": 12,
"attackCooldownSeconds": 4.0 "attackCooldownSeconds": 4.0,
"attackAbilityId": "prototype_npc_ranged_shot"
}, },
{ {
"id": "prototype_elite_mini_boss", "id": "prototype_elite_mini_boss",
@ -32,7 +34,8 @@
"leashRadius": 18.0, "leashRadius": 18.0,
"telegraphWindupSeconds": 2.5, "telegraphWindupSeconds": 2.5,
"attackDamage": 25, "attackDamage": 25,
"attackCooldownSeconds": 5.0 "attackCooldownSeconds": 5.0,
"attackAbilityId": "prototype_npc_elite_slam"
} }
] ]
} }

View File

@ -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.", "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", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "displayName", "baseDamage", "cooldownSeconds"], "required": ["id", "displayName", "baseDamage", "maxRange", "cooldownSeconds"],
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
@ -22,6 +22,11 @@
"minimum": 0, "minimum": 0,
"description": "Deterministic damage dealt on successful combat resolve in prototype Slice 1 (no miss/crit RNG)." "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": { "cooldownSeconds": {
"type": "number", "type": "number",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,

View File

@ -14,7 +14,8 @@
"leashRadius", "leashRadius",
"telegraphWindupSeconds", "telegraphWindupSeconds",
"attackDamage", "attackDamage",
"attackCooldownSeconds" "attackCooldownSeconds",
"attackAbilityId"
], ],
"properties": { "properties": {
"id": { "id": {
@ -40,7 +41,7 @@
"aggroRadius": { "aggroRadius": {
"type": "number", "type": "number",
"exclusiveMinimum": 0, "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": { "leashRadius": {
"type": "number", "type": "number",
@ -61,6 +62,11 @@
"type": "number", "type": "number",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"description": "Seconds after last attack before the NPC may enter telegraph again." "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)."
} }
} }
} }

View File

@ -7,8 +7,8 @@
| **Module ID** | E5.M2 | | **Module ID** | E5.M2 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype | | **Stage target** | Prototype |
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | | **Status** | Ready — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) client telegraph HUD landed · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) playable capstone landed |
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | | **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 ## 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. 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` | | `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_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_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_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`**). **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/`. **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). **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). **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/`. **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 ## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -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.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-92NEO-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 ### Epic 6 — PvP Security

View File

@ -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 121 completable in one session without Bruno/curl.
- [ ] Melee telegraph countdown readable **before** player HP drops to **85/100** (steps 78).
- [ ] All three archetypes show **`defeated`** in **`CombatTargetHpLabel`** (steps 9, 14, 18).
- [ ] Aggro on first damaging cast feels deterministic (no silent failures).

View File

@ -375,9 +375,12 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria** **Acceptance criteria**
- [ ] Human completes script with server + client; telegraph timing readable before damage. - [x] 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. - [x] 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] 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**). **Client counterpart:** this issue (**NEO-98**).

View File

@ -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-92NEO-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-92NEO-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-92NEO-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** |

View File

@ -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 1921), 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 34 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 1920).
**Optional:** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) incoming-threat row regression.

View File

@ -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-92NEO-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 810) 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-06E5M2-11 inline — optional consistency pass.~~ **Done.** Linear row lists E5M2-01E5M2-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 310 (telegraph visible **before** **85/100**), Ranged + Elite defeat with telegraph visible, regression steps 1921 (**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.

View File

@ -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. # 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. # Keep in sync with E5.M2 freeze table and future NEO-88 server loader.
PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset( PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
@ -428,10 +439,11 @@ def _validate_ability_catalogs(
*, *,
ability_files: list[Path], ability_files: list[Path],
ability_validator: Draft202012Validator, ability_validator: Draft202012Validator,
) -> tuple[int, dict[str, str]]: ) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate ability JSON files. Returns (error_count, seen_ids).""" """Validate ability JSON files. Returns (error_count, seen_ids, id_to_row)."""
errors = 0 errors = 0
seen_ids: dict[str, str] = {} seen_ids: dict[str, str] = {}
id_to_row: dict[str, dict] = {}
for path in ability_files: for path in ability_files:
rel = str(path.relative_to(REPO_ROOT)) rel = str(path.relative_to(REPO_ROOT))
@ -467,17 +479,18 @@ def _validate_ability_catalogs(
errors += 1 errors += 1
else: else:
seen_ids[aid] = rel 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: def _prototype_ability_catalog_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E5M1 ability contract fails, else None.""" """Return a human-readable error if prototype ability catalog contract fails, else None."""
ids = frozenset(seen_ids.keys()) ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E5M1_ABILITY_IDS: if ids != PROTOTYPE_ABILITY_CATALOG_IDS:
return ( return (
"error: prototype E5M1 expects exactly ability ids " "error: prototype ability catalog expects exactly ability ids "
f"{sorted(PROTOTYPE_E5M1_ABILITY_IDS)!r}, got {sorted(ids)!r}" f"{sorted(PROTOTYPE_ABILITY_CATALOG_IDS)!r}, got {sorted(ids)!r}"
) )
return None return None
@ -555,6 +568,30 @@ def _prototype_e5m2_npc_behavior_numeric_gate(id_to_row: dict[str, dict]) -> str
return None 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: def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None.""" """Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1: if len(track_skill_ids) != 1:
@ -1161,7 +1198,7 @@ def main() -> int:
print(slice3_recipe_err, file=sys.stderr) print(slice3_recipe_err, file=sys.stderr)
return 1 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_files=ability_files,
ability_validator=ability_validator, ability_validator=ability_validator,
) )
@ -1169,9 +1206,9 @@ def main() -> int:
print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr) print(f"content validation failed with {ability_errors} error(s)", file=sys.stderr)
return 1 return 1
e5m1_ability_err = _prototype_e5m1_ability_gate(ability_seen_ids) ability_catalog_err = _prototype_ability_catalog_gate(ability_seen_ids)
if e5m1_ability_err: if ability_catalog_err:
print(e5m1_ability_err, file=sys.stderr) print(ability_catalog_err, file=sys.stderr)
return 1 return 1
npc_behavior_errors, npc_behavior_seen_ids, npc_behavior_id_to_row = _validate_npc_behavior_catalogs( 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) print(e5m2_npc_behavior_numeric_err, file=sys.stderr)
return 1 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( print(
"content OK: " "content OK: "
f"{len(skill_files)} skill catalog file(s), " f"{len(skill_files)} skill catalog file(s), "

View File

@ -652,6 +652,49 @@ public sealed class AbilityCastApiTests
Assert.Null(fifth.CombatResolution); 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<AbilityCastResponse>();
Assert.NotNull(fourth);
Assert.True(fourth!.CombatResolution!.TargetDefeated);
snapshotResponse.EnsureSuccessStatusCode();
var snapshotBody = await snapshotResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
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<PlayerCombatHealthResponse>();
Assert.NotNull(healthBody);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, healthBody!.CurrentHp);
}
[Fact] [Fact]
public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression() public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression()
{ {

View File

@ -21,6 +21,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_pulse", "id": "prototype_pulse",
"displayName": "Prototype Pulse", "displayName": "Prototype Pulse",
"baseDamage": 25, "baseDamage": 25,
"maxRange": 6.0,
"cooldownSeconds": 3.0, "cooldownSeconds": 3.0,
"abilityKind": "attack" "abilityKind": "attack"
}, },
@ -28,6 +29,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_guard", "id": "prototype_guard",
"displayName": "Prototype Guard", "displayName": "Prototype Guard",
"baseDamage": 0, "baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 6.0, "cooldownSeconds": 6.0,
"abilityKind": "utility" "abilityKind": "utility"
}, },
@ -35,6 +37,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_dash", "id": "prototype_dash",
"displayName": "Prototype Dash", "displayName": "Prototype Dash",
"baseDamage": 0, "baseDamage": 0,
"maxRange": 6.0,
"cooldownSeconds": 4.0, "cooldownSeconds": 4.0,
"abilityKind": "movement" "abilityKind": "movement"
}, },
@ -42,6 +45,40 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_burst", "id": "prototype_burst",
"displayName": "Prototype Burst", "displayName": "Prototype Burst",
"baseDamage": 40, "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, "cooldownSeconds": 5.0,
"abilityKind": "attack" "abilityKind": "attack"
} }
@ -64,6 +101,15 @@ public class AbilityDefinitionCatalogLoaderTests
private static void WriteCatalog(string abilitiesDir, string catalogJson) => private static void WriteCatalog(string abilitiesDir, string catalogJson) =>
File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8); 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) private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId)
{ {
var abilities = catalogRoot["abilities"] as JsonArray var abilities = catalogRoot["abilities"] as JsonArray
@ -85,12 +131,12 @@ public class AbilityDefinitionCatalogLoaderTests
{ {
// Arrange // Arrange
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); WriteFullPrototypeCatalog(abilitiesDir);
// Act // Act
var catalog = LoadCatalog(abilitiesDir, schemaPath); var catalog = LoadCatalog(abilitiesDir, schemaPath);
// Assert // Assert
Assert.Equal(4, catalog.DistinctAbilityCount); Assert.Equal(7, catalog.DistinctAbilityCount);
Assert.Equal(1, catalog.CatalogJsonFileCount); Assert.Equal(2, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse)); Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse));
Assert.NotNull(pulse); Assert.NotNull(pulse);
Assert.Equal(25, pulse!.BaseDamage); Assert.Equal(25, pulse!.BaseDamage);
@ -136,7 +182,7 @@ public class AbilityDefinitionCatalogLoaderTests
{ {
// Arrange // Arrange
var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); var (_, abilitiesDir, schemaPath) = CreateTempContentLayout();
WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); WriteFullPrototypeCatalog(abilitiesDir);
File.WriteAllText( File.WriteAllText(
Path.Combine(abilitiesDir, "extra_abilities.json"), Path.Combine(abilitiesDir, "extra_abilities.json"),
""" """
@ -147,6 +193,7 @@ public class AbilityDefinitionCatalogLoaderTests
"id": "prototype_pulse", "id": "prototype_pulse",
"displayName": "Duplicate Pulse", "displayName": "Duplicate Pulse",
"baseDamage": 1, "baseDamage": 1,
"maxRange": 6.0,
"cooldownSeconds": 1.0 "cooldownSeconds": 1.0
} }
] ]
@ -171,11 +218,15 @@ public class AbilityDefinitionCatalogLoaderTests
?? throw new InvalidOperationException("expected abilities array"); ?? throw new InvalidOperationException("expected abilities array");
abilities.RemoveAt(abilities.Count - 1); abilities.RemoveAt(abilities.Count - 1);
WriteCatalog(abilitiesDir, root.ToJsonString()); WriteCatalog(abilitiesDir, root.ToJsonString());
File.WriteAllText(
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
ValidNpcAttackAbilitiesJson,
Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(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] [Fact]
@ -187,11 +238,15 @@ public class AbilityDefinitionCatalogLoaderTests
?? throw new InvalidOperationException("expected object root"); ?? throw new InvalidOperationException("expected object root");
GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra"; GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra";
WriteCatalog(abilitiesDir, root.ToJsonString()); WriteCatalog(abilitiesDir, root.ToJsonString());
File.WriteAllText(
Path.Combine(abilitiesDir, "prototype_npc_abilities.json"),
ValidNpcAttackAbilitiesJson,
Encoding.UTF8);
// Act // Act
var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath));
// Assert // Assert
var ioe = Assert.IsType<InvalidOperationException>(ex); var ioe = Assert.IsType<InvalidOperationException>(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); Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal);
} }
@ -316,7 +371,7 @@ public class AbilityDefinitionCatalogLoaderTests
// Assert // Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = factory.Services.GetRequiredService<AbilityDefinitionCatalog>(); var catalog = factory.Services.GetRequiredService<AbilityDefinitionCatalog>();
Assert.Equal(4, catalog.DistinctAbilityCount); Assert.Equal(7, catalog.DistinctAbilityCount);
Assert.True(catalog.TryGetAbility("prototype_burst", out var burst)); Assert.True(catalog.TryGetAbility("prototype_burst", out var burst));
Assert.Equal(40, burst!.BaseDamage); Assert.Equal(40, burst!.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds); Assert.Equal(5.0, burst.CooldownSeconds);

View File

@ -27,6 +27,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -52,6 +53,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Burst", "Prototype Burst",
40, 40,
5.0, 5.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -75,6 +77,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -96,6 +99,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -117,6 +121,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -138,6 +143,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -159,6 +165,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -180,6 +187,7 @@ public class AbilityDefinitionRegistryTests
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -201,24 +209,28 @@ public class AbilityDefinitionRegistryTests
"Prototype Burst", "Prototype Burst",
40, 40,
5.0, 5.0,
6.0,
"attack"), "attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow( [PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash, PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash", "Prototype Dash",
0, 0,
4.0, 4.0,
6.0,
"movement"), "movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow( [PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard, PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard", "Prototype Guard",
0, 0,
6.0, 6.0,
6.0,
"utility"), "utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow( [PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var registry = CreateRegistryFromRows(rows); var registry = CreateRegistryFromRows(rows);
@ -249,6 +261,10 @@ public class AbilityDefinitionRegistryTests
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"), Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
Path.Combine(abilitiesDir, "prototype_abilities.json"), Path.Combine(abilitiesDir, "prototype_abilities.json"),
overwrite: true); 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 loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
var registry = new AbilityDefinitionRegistry(loaded); var registry = new AbilityDefinitionRegistry(loaded);
// Act // Act
@ -274,7 +290,7 @@ public class AbilityDefinitionRegistryTests
Assert.NotNull(burst); Assert.NotNull(burst);
Assert.Equal(40, burst!.BaseDamage); Assert.Equal(40, burst!.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds); 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.PrototypeBurst, list[0].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id); Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id);
} }
@ -313,7 +329,7 @@ public class AbilityDefinitionRegistryTests
Assert.Null(missing); Assert.Null(missing);
Assert.True(normalized); Assert.True(normalized);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId); 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); Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0);
} }
} }

View File

@ -8,17 +8,20 @@ namespace NeonSprawl.Server.Tests.Game.Combat;
public class AbilityDefinitionsWorldApiTests public class AbilityDefinitionsWorldApiTests
{ {
/// <summary>Frozen prototype four in registry id order (ordinal). Keep in sync with Bruno.</summary> /// <summary>Frozen prototype seven in registry id order (ordinal). Keep in sync with Bruno.</summary>
public static readonly string[] FrozenFourInIdOrder = public static readonly string[] FrozenSevenInIdOrder =
[ [
"prototype_burst", "prototype_burst",
"prototype_dash", "prototype_dash",
"prototype_guard", "prototype_guard",
"prototype_npc_elite_slam",
"prototype_npc_melee_strike",
"prototype_npc_ranged_shot",
"prototype_pulse", "prototype_pulse",
]; ];
[Fact] [Fact]
public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenFourInIdOrder() public async Task GetAbilityDefinitions_ShouldReturnSchemaV1_WithFrozenSevenInIdOrder()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
@ -31,30 +34,38 @@ public class AbilityDefinitionsWorldApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.Equal(AbilityDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Abilities); 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(); 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"); var pulse = body.Abilities.Single(a => a.Id == "prototype_pulse");
Assert.Equal("Prototype Pulse", pulse.DisplayName); Assert.Equal("Prototype Pulse", pulse.DisplayName);
Assert.Equal(25, pulse.BaseDamage); Assert.Equal(25, pulse.BaseDamage);
Assert.Equal(3.0, pulse.CooldownSeconds); Assert.Equal(3.0, pulse.CooldownSeconds);
Assert.Equal(6.0, pulse.MaxRange);
Assert.Equal("attack", pulse.AbilityKind); Assert.Equal("attack", pulse.AbilityKind);
var burst = body.Abilities.Single(a => a.Id == "prototype_burst"); var burst = body.Abilities.Single(a => a.Id == "prototype_burst");
Assert.Equal("Prototype Burst", burst.DisplayName); Assert.Equal("Prototype Burst", burst.DisplayName);
Assert.Equal(40, burst.BaseDamage); Assert.Equal(40, burst.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds); Assert.Equal(5.0, burst.CooldownSeconds);
Assert.Equal(6.0, burst.MaxRange);
Assert.Equal("attack", burst.AbilityKind); Assert.Equal("attack", burst.AbilityKind);
var guard = body.Abilities.Single(a => a.Id == "prototype_guard"); var guard = body.Abilities.Single(a => a.Id == "prototype_guard");
Assert.Equal("utility", guard.AbilityKind); Assert.Equal("utility", guard.AbilityKind);
Assert.Equal(0, guard.BaseDamage); Assert.Equal(0, guard.BaseDamage);
Assert.Equal(6.0, guard.CooldownSeconds); Assert.Equal(6.0, guard.CooldownSeconds);
Assert.Equal(6.0, guard.MaxRange);
var dash = body.Abilities.Single(a => a.Id == "prototype_dash"); var dash = body.Abilities.Single(a => a.Id == "prototype_dash");
Assert.Equal("movement", dash.AbilityKind); Assert.Equal("movement", dash.AbilityKind);
Assert.Equal(0, dash.BaseDamage); Assert.Equal(0, dash.BaseDamage);
Assert.Equal(4.0, dash.CooldownSeconds); 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);
} }
} }

View File

@ -19,24 +19,28 @@ public sealed class CombatOperationsTests
"Prototype Burst", "Prototype Burst",
40, 40,
5.0, 5.0,
6.0,
"attack"), "attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow( [PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash, PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash", "Prototype Dash",
0, 0,
4.0, 4.0,
6.0,
"movement"), "movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow( [PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard, PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard", "Prototype Guard",
0, 0,
6.0, 6.0,
6.0,
"utility"), "utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow( [PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse", "Prototype Pulse",
25, 25,
3.0, 3.0,
6.0,
"attack"), "attack"),
}; };
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1); var catalog = new AbilityDefinitionCatalog("/tmp/abilities", rows, catalogJsonFileCount: 1);

View File

@ -1,23 +1,38 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat; namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class NpcAttackOperationsTests 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] [Fact]
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches() public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatchesAndInRange()
{ {
// Arrange // Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore(); var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act // Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete( var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1", "dev-local-1",
attackDamage: 15, PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore, playerHealthStore,
threatStore); threatStore);
// Assert // Assert
@ -26,19 +41,71 @@ public sealed class NpcAttackOperationsTests
Assert.Equal(85, snapshot.CurrentHp); 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] [Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared() public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared()
{ {
// Arrange // Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore(); var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
_ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); _ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act // Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete( var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1", "dev-local-1",
attackDamage: 15, PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore, playerHealthStore,
threatStore); threatStore);
// Assert // Assert
@ -51,14 +118,18 @@ public sealed class NpcAttackOperationsTests
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers() public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers()
{ {
// Arrange // Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore(); var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player"); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player");
// Act // Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete( var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1", "dev-local-1",
attackDamage: 15, PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore, playerHealthStore,
threatStore); threatStore);
// Assert // Assert
@ -68,17 +139,21 @@ public sealed class NpcAttackOperationsTests
} }
[Fact] [Fact]
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero() public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAbilityBaseDamageZero()
{ {
// Arrange // Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore(); var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore(); var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1"); _ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act // Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete( var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1", "dev-local-1",
attackDamage: 0, PrototypeAbilityRegistry.PrototypeGuard,
abilityRegistry,
positionStore,
playerHealthStore, playerHealthStore,
threatStore); threatStore);
// Assert // Assert

View File

@ -26,7 +26,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 16.0, "leashRadius": 16.0,
"telegraphWindupSeconds": 1.5, "telegraphWindupSeconds": 1.5,
"attackDamage": 15, "attackDamage": 15,
"attackCooldownSeconds": 3.0 "attackCooldownSeconds": 3.0,
"attackAbilityId": "prototype_npc_melee_strike"
}, },
{ {
"id": "prototype_ranged_control", "id": "prototype_ranged_control",
@ -37,7 +38,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 20.0, "leashRadius": 20.0,
"telegraphWindupSeconds": 2.0, "telegraphWindupSeconds": 2.0,
"attackDamage": 12, "attackDamage": 12,
"attackCooldownSeconds": 4.0 "attackCooldownSeconds": 4.0,
"attackAbilityId": "prototype_npc_ranged_shot"
}, },
{ {
"id": "prototype_elite_mini_boss", "id": "prototype_elite_mini_boss",
@ -48,7 +50,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 18.0, "leashRadius": 18.0,
"telegraphWindupSeconds": 2.5, "telegraphWindupSeconds": 2.5,
"attackDamage": 25, "attackDamage": 25,
"attackCooldownSeconds": 5.0 "attackCooldownSeconds": 5.0,
"attackAbilityId": "prototype_npc_elite_slam"
} }
] ]
} }
@ -159,7 +162,8 @@ public class NpcBehaviorDefinitionCatalogLoaderTests
"leashRadius": 2.0, "leashRadius": 2.0,
"telegraphWindupSeconds": 1.0, "telegraphWindupSeconds": 1.0,
"attackDamage": 1, "attackDamage": 1,
"attackCooldownSeconds": 1.0 "attackCooldownSeconds": 1.0,
"attackAbilityId": "prototype_npc_melee_strike"
} }
] ]
} }

View File

@ -1,6 +1,7 @@
using System.IO; using System.IO;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -25,7 +26,8 @@ public class NpcBehaviorDefinitionRegistryTests
16.0, 16.0,
1.5, 1.5,
15, 15,
3.0); 3.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike);
private static NpcBehaviorDefRow RangedRow() => private static NpcBehaviorDefRow RangedRow() =>
new( new(
@ -37,7 +39,8 @@ public class NpcBehaviorDefinitionRegistryTests
20.0, 20.0,
2.0, 2.0,
12, 12,
4.0); 4.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot);
private static NpcBehaviorDefRow EliteRow() => private static NpcBehaviorDefRow EliteRow() =>
new( new(
@ -49,7 +52,8 @@ public class NpcBehaviorDefinitionRegistryTests
18.0, 18.0,
2.5, 2.5,
25, 25,
5.0); 5.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam);
[Fact] [Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists() public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenMeleeExists()

View File

@ -43,6 +43,7 @@ public class NpcBehaviorDefinitionsWorldApiTests
Assert.Equal(1.5, melee.TelegraphWindupSeconds); Assert.Equal(1.5, melee.TelegraphWindupSeconds);
Assert.Equal(15, melee.AttackDamage); Assert.Equal(15, melee.AttackDamage);
Assert.Equal(3.0, melee.AttackCooldownSeconds); Assert.Equal(3.0, melee.AttackCooldownSeconds);
Assert.Equal("prototype_npc_melee_strike", melee.AttackAbilityId);
var elite = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss); var elite = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss);
Assert.Equal("Elite Mini-Boss", elite.DisplayName); Assert.Equal("Elite Mini-Boss", elite.DisplayName);
@ -53,5 +54,6 @@ public class NpcBehaviorDefinitionsWorldApiTests
Assert.Equal(2.5, elite.TelegraphWindupSeconds); Assert.Equal(2.5, elite.TelegraphWindupSeconds);
Assert.Equal(25, elite.AttackDamage); Assert.Equal(25, elite.AttackDamage);
Assert.Equal(5.0, elite.AttackCooldownSeconds); Assert.Equal(5.0, elite.AttackCooldownSeconds);
Assert.Equal("prototype_npc_elite_slam", elite.AttackAbilityId);
} }
} }

View File

@ -1,5 +1,7 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc; namespace NeonSprawl.Server.Tests.Game.Npc;
@ -9,13 +11,23 @@ public sealed class NpcRuntimeOperationsTests
private static readonly DateTimeOffset T0 = private static readonly DateTimeOffset T0 =
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero); new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior, IPlayerCombatHealthStore PlayerHealth) private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) =>
CreateFixture() 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 ( return (
new InMemoryNpcRuntimeStateStore(), new InMemoryNpcRuntimeStateStore(),
new InMemoryThreatStateStore(), new InMemoryThreatStateStore(),
PrototypeNpcTestFixtures.CreateBehaviorRegistry(), PrototypeNpcTestFixtures.CreateBehaviorRegistry(),
PrototypeNpcTestFixtures.CreateAbilityRegistry(),
PrototypeNpcTestFixtures.CreateHealthStore(),
CreatePositionStoreAt(playerX, playerY, playerZ),
new InMemoryPlayerCombatHealthStore()); new InMemoryPlayerCombatHealthStore());
} }
@ -27,6 +39,9 @@ public sealed class NpcRuntimeOperationsTests
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
InMemoryCombatEntityHealthStore combatHealthStore,
InMemoryPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore, IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
NpcRuntimeOperations.AdvanceAll( NpcRuntimeOperations.AdvanceAll(
@ -34,6 +49,9 @@ public sealed class NpcRuntimeOperationsTests
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry, behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore, playerHealthStore,
maxDeltaSeconds); maxDeltaSeconds);
@ -41,9 +59,9 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
// Act // Act
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -54,10 +72,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act // Act
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -69,10 +87,10 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder() public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
runtime.LastAdvancedUtc = T0; runtime.LastAdvancedUtc = T0;
// Act // Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(10), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -83,11 +101,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -99,11 +117,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(2.9), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(2.9), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -113,12 +131,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -128,13 +146,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -146,13 +164,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(7.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -164,11 +182,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall() public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // 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 // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -180,13 +198,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act // Act
Advance(T0.AddSeconds(3.5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
@ -201,11 +219,11 @@ public sealed class NpcRuntimeOperationsTests
double attackCooldownSeconds) double attackCooldownSeconds)
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId); SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(npcInstanceId, out var snapshot); runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
@ -221,12 +239,12 @@ public sealed class NpcRuntimeOperationsTests
double telegraphWindupSeconds) double telegraphWindupSeconds)
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, npcInstanceId); SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
runtime.TryGet(npcInstanceId, out var snapshot); runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -239,11 +257,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // 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 // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
@ -255,12 +273,12 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap() public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(100), runtime, threat, behavior, playerHealth, maxDeltaSeconds: 5); Advance(T0.AddSeconds(100), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth, maxDeltaSeconds: 5);
// Act // 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
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
@ -272,14 +290,14 @@ public sealed class NpcRuntimeOperationsTests
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
runtime.LastAdvancedUtc = T0.AddHours(1); runtime.LastAdvancedUtc = T0.AddHours(1);
NpcRuntimeOperations.ResetAllPrototypeRows(runtime); NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act // 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 // Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
@ -291,11 +309,11 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
runtime.LastAdvancedUtc = T0.AddSeconds(10); runtime.LastAdvancedUtc = T0.AddSeconds(10);
// Act // Act
Advance(T0.AddSeconds(5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
} }
@ -304,13 +322,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.4), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Act // Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
playerHealth.TryGet("dev-local-1", out var snapshot); playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(85, snapshot.CurrentHp); Assert.Equal(85, snapshot.CurrentHp);
@ -320,13 +338,13 @@ public sealed class NpcRuntimeOperationsTests
public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes()
{ {
// Arrange // Arrange
var (runtime, threat, behavior, playerHealth) = CreateFixture(); var (runtime, threat, behavior, abilities, combatHealth, positions, playerHealth) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior, playerHealth); Advance(T0, runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
Advance(T0.AddSeconds(3), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act // Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior, playerHealth); Advance(T0.AddSeconds(4.5), runtime, threat, behavior, abilities, combatHealth, positions, playerHealth);
// Assert // Assert
playerHealth.TryGet("dev-local-1", out var snapshot); playerHealth.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
@ -347,7 +365,8 @@ public sealed class NpcRuntimeOperationsTests
16.0, 16.0,
1.5, 1.5,
15, 15,
0.0); 0.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike);
var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 }; var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 };
var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 }; var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 };
// Act // Act
@ -359,4 +378,78 @@ public sealed class NpcRuntimeOperationsTests
Assert.False(windupOk); Assert.False(windupOk);
Assert.True(validOk); 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);
}
} }

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
@ -6,6 +7,65 @@ namespace NeonSprawl.Server.Tests.Game.Npc;
/// <summary>Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91).</summary> /// <summary>Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91).</summary>
public static class PrototypeNpcTestFixtures public static class PrototypeNpcTestFixtures
{ {
public static IAbilityDefinitionRegistry CreateAbilityRegistry()
{
var rows = new Dictionary<string, AbilityDefRow>(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() public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry()
{ {
var byId = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal) var byId = new Dictionary<string, NpcBehaviorDefRow>(StringComparer.Ordinal)
@ -19,7 +79,8 @@ public static class PrototypeNpcTestFixtures
16.0, 16.0,
1.5, 1.5,
15, 15,
3.0), 3.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike),
[PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new( [PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new(
PrototypeNpcBehaviorRegistry.PrototypeRangedControl, PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
"Ranged Control", "Ranged Control",
@ -29,7 +90,8 @@ public static class PrototypeNpcTestFixtures
20.0, 20.0,
2.0, 2.0,
12, 12,
4.0), 4.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot),
[PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new( [PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new(
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
"Elite Mini-Boss", "Elite Mini-Boss",
@ -39,7 +101,8 @@ public static class PrototypeNpcTestFixtures
18.0, 18.0,
2.5, 2.5,
25, 25,
5.0), 5.0,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam),
}; };
var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1); var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1);

View File

@ -66,6 +66,7 @@ public static class AbilityCastApi
ICombatEntityHealthStore healthStore, ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
INpcRuntimeStateStore npcRuntimeStore,
IPlayerGigProgressionStore gigStore, IPlayerGigProgressionStore gigStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider clock) => TimeProvider clock) =>
@ -243,6 +244,11 @@ public static class AbilityCastApi
if (combatResult.TargetDefeated) if (combatResult.TargetDefeated)
{ {
NpcRuntimeOperations.TryStopOnTargetDefeat(
lookupKey,
healthStore,
threatStore,
npcRuntimeStore);
CombatDefeatGigXpGrant.GrantOnCombatDefeat( CombatDefeatGigXpGrant.GrantOnCombatDefeat(
id.Trim(), id.Trim(),
gigStore, gigStore,

View File

@ -6,4 +6,5 @@ public sealed record AbilityDefRow(
string DisplayName, string DisplayName,
int BaseDamage, int BaseDamage,
double CooldownSeconds, double CooldownSeconds,
double MaxRange,
string? AbilityKind); string? AbilityKind);

View File

@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>(); var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>(); var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>(); var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue<double>();
string? abilityKind = null; string? abilityKind = null;
if (rowObj["abilityKind"] is JsonValue abilityKindValue) if (rowObj["abilityKind"] is JsonValue abilityKindValue)
abilityKind = abilityKindValue.GetValue<string>(); abilityKind = abilityKindValue.GetValue<string>();
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind); return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, maxRange, abilityKind);
} }
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index) private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -30,6 +30,9 @@ public sealed class AbilityDefinitionJson
[JsonPropertyName("cooldownSeconds")] [JsonPropertyName("cooldownSeconds")]
public required double CooldownSeconds { get; init; } public required double CooldownSeconds { get; init; }
[JsonPropertyName("maxRange")]
public required double MaxRange { get; init; }
[JsonPropertyName("abilityKind")] [JsonPropertyName("abilityKind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AbilityKind { get; init; } public string? AbilityKind { get; init; }

View File

@ -20,6 +20,7 @@ public static class AbilityDefinitionsWorldApi
DisplayName = d.DisplayName, DisplayName = d.DisplayName,
BaseDamage = d.BaseDamage, BaseDamage = d.BaseDamage,
CooldownSeconds = d.CooldownSeconds, CooldownSeconds = d.CooldownSeconds,
MaxRange = d.MaxRange,
AbilityKind = d.AbilityKind, AbilityKind = d.AbilityKind,
}); });
} }

View File

@ -1,22 +1,32 @@
using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
/// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95).</summary> /// <summary>Deterministic NPC telegraph-complete damage resolve (NEO-95 / NEO-98).</summary>
public static class NpcAttackOperations public static class NpcAttackOperations
{ {
/// <summary> /// <summary>
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when /// Applies catalog ability <c>baseDamage</c> to <paramref name="holderPlayerId"/> when the threat row still
/// the threat row still names that holder for <paramref name="npcInstanceId"/>. /// names that holder for <paramref name="npcInstanceId"/> and the holder is within the attack ability's
/// <see cref="AbilityDefRow.MaxRange"/> of the NPC anchor.
/// </summary> /// </summary>
public static bool TryResolveTelegraphComplete( public static bool TryResolveTelegraphComplete(
string npcInstanceId, string npcInstanceId,
string holderPlayerId, string holderPlayerId,
int attackDamage, string attackAbilityId,
IAbilityDefinitionRegistry abilityRegistry,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore, IPlayerCombatHealthStore playerHealthStore,
IThreatStateStore threatStore) IThreatStateStore threatStore)
{ {
if (attackDamage <= 0) if (!abilityRegistry.TryGetDefinition(attackAbilityId, out var ability))
{
return false;
}
if (ability.BaseDamage <= 0)
{ {
return true; return true;
} }
@ -29,7 +39,12 @@ public static class NpcAttackOperations
return false; 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; return false;
} }
@ -41,4 +56,25 @@ public static class NpcAttackOperations
return true; 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);
}
} }

View File

@ -3,8 +3,9 @@ using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
/// <summary> /// <summary>
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c> /// Prototype ability roster gate (NEO-76 / NEO-77 / NEO-98), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</c>. /// <c>PROTOTYPE_E5M1_ABILITY_IDS</c>, <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>, and
/// <c>_prototype_ability_catalog_gate</c>.
/// </summary> /// </summary>
public static class PrototypeE5M1AbilityCatalogRules public static class PrototypeE5M1AbilityCatalogRules
{ {
@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules
], ],
StringComparer.Ordinal); StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the E5M1 ability contract fails, otherwise <see langword="null"/>.</summary> /// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_ATTACK_ABILITY_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedNpcAttackAbilityIds = FrozenSet.ToFrozenSet(
[
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcRangedShot,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcEliteSlam,
],
StringComparer.Ordinal);
/// <summary>Union of player hotbar abilities and NPC attack abilities loaded at startup.</summary>
public static readonly FrozenSet<string> ExpectedCatalogAbilityIds = ExpectedAbilityIds
.Union(ExpectedNpcAttackAbilityIds)
.ToFrozenSet(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the prototype ability catalog contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> abilityIdToSourceFile) public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> abilityIdToSourceFile)
{ {
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedAbilityIds)) if (!ids.SetEquals(ExpectedCatalogAbilityIds))
{ {
return return
"error: prototype E5M1 expects exactly ability ids " + "error: prototype ability catalog expects exactly ability ids " +
$"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + $"[{string.Join(", ", ExpectedCatalogAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
} }

View File

@ -0,0 +1,9 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Stable prototype NPC attack ability id constants (NEO-98). Allowlist validation uses <see cref="IAbilityDefinitionRegistry"/>.</summary>
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";
}

View File

@ -1,6 +1,7 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Npc;
@ -29,7 +30,17 @@ public static class NpcBehaviorCatalogServiceCollectionExtensions
opts.NpcBehaviorDefSchemaPath, opts.NpcBehaviorDefSchemaPath,
hostEnv.ContentRootPath); hostEnv.ContentRootPath);
return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); var catalog = NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger);
var abilityRegistry = sp.GetRequiredService<IAbilityDefinitionRegistry>();
var crossRefErr = PrototypeE5M2NpcBehaviorCatalogRules.TryGetAttackAbilityCrossRefError(
catalog.ById,
abilityRegistry);
if (crossRefErr is not null)
{
throw new InvalidOperationException(crossRefErr);
}
return catalog;
}); });
services.AddSingleton<INpcBehaviorDefinitionRegistry>(sp => services.AddSingleton<INpcBehaviorDefinitionRegistry>(sp =>

View File

@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow(
double LeashRadius, double LeashRadius,
double TelegraphWindupSeconds, double TelegraphWindupSeconds,
int AttackDamage, int AttackDamage,
double AttackCooldownSeconds); double AttackCooldownSeconds,
string AttackAbilityId);

View File

@ -152,6 +152,7 @@ public static class NpcBehaviorDefinitionCatalogLoader
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>(); var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>(); var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>(); var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue<string>();
return new NpcBehaviorDefRow( return new NpcBehaviorDefRow(
id, id,
@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader
leashRadius, leashRadius,
telegraphWindupSeconds, telegraphWindupSeconds,
attackDamage, attackDamage,
attackCooldownSeconds); attackCooldownSeconds,
attackAbilityId);
} }
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index) private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson
[JsonPropertyName("attackCooldownSeconds")] [JsonPropertyName("attackCooldownSeconds")]
public required double AttackCooldownSeconds { get; init; } public required double AttackCooldownSeconds { get; init; }
[JsonPropertyName("attackAbilityId")]
public required string AttackAbilityId { get; init; }
} }

View File

@ -25,6 +25,7 @@ public static class NpcBehaviorDefinitionsWorldApi
TelegraphWindupSeconds = d.TelegraphWindupSeconds, TelegraphWindupSeconds = d.TelegraphWindupSeconds,
AttackDamage = d.AttackDamage, AttackDamage = d.AttackDamage,
AttackCooldownSeconds = d.AttackCooldownSeconds, AttackCooldownSeconds = d.AttackCooldownSeconds,
AttackAbilityId = d.AttackAbilityId,
}); });
} }

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Npc;
@ -20,6 +21,9 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore combatHealthStore,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore, IPlayerCombatHealthStore playerHealthStore,
double maxDeltaSeconds = DefaultMaxDeltaSeconds) double maxDeltaSeconds = DefaultMaxDeltaSeconds)
{ {
@ -40,6 +44,9 @@ public static class NpcRuntimeOperations
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry, behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore); playerHealthStore);
} }
@ -59,6 +66,9 @@ public static class NpcRuntimeOperations
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry, behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore); playerHealthStore);
} }
@ -78,6 +88,9 @@ public static class NpcRuntimeOperations
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry, behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore); playerHealthStore);
} }
@ -92,6 +105,27 @@ public static class NpcRuntimeOperations
runtimeStore.ResetAllPrototypeRows(); runtimeStore.ResetAllPrototypeRows();
} }
/// <summary>
/// Clears aggro and runtime combat state when the NPC combat-target row is <see cref="CombatEntityHealthSnapshot.Defeated"/>.
/// </summary>
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( private static void AdvanceOne(
string npcInstanceId, string npcInstanceId,
DateTimeOffset windowStart, DateTimeOffset windowStart,
@ -99,6 +133,9 @@ public static class NpcRuntimeOperations
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore combatHealthStore,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore) IPlayerCombatHealthStore playerHealthStore)
{ {
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) || if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
@ -117,6 +154,19 @@ public static class NpcRuntimeOperations
return; 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 (!TryHasAggroHolder(npcInstanceId, threatStore, out var aggroHolderPlayerId))
{ {
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null) if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
@ -212,7 +262,9 @@ public static class NpcRuntimeOperations
NpcAttackOperations.TryResolveTelegraphComplete( NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId, npcInstanceId,
aggroHolderPlayerId, aggroHolderPlayerId,
behavior.AttackDamage, behavior.AttackAbilityId,
abilityRegistry,
positionStore,
playerHealthStore, playerHealthStore,
threatStore); threatStore);
WriteState( WriteState(
@ -233,7 +285,9 @@ public static class NpcRuntimeOperations
NpcAttackOperations.TryResolveTelegraphComplete( NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId, npcInstanceId,
aggroHolderPlayerId, aggroHolderPlayerId,
behavior.AttackDamage, behavior.AttackAbilityId,
abilityRegistry,
positionStore,
playerHealthStore, playerHealthStore,
threatStore); threatStore);
WriteState( WriteState(
@ -326,4 +380,7 @@ public static class NpcRuntimeOperations
npcInstanceId, npcInstanceId,
new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph)); new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph));
} }
private static string NormalizeNpcId(string? raw) =>
raw?.Trim().ToLowerInvariant() ?? string.Empty;
} }

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Npc;
@ -14,6 +15,9 @@ public static class NpcRuntimeSnapshotWorldApi
INpcRuntimeStateStore runtimeStore, INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore, IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry, INpcBehaviorDefinitionRegistry behaviorRegistry,
IAbilityDefinitionRegistry abilityRegistry,
ICombatEntityHealthStore combatHealthStore,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore) => IPlayerCombatHealthStore playerHealthStore) =>
{ {
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
@ -22,6 +26,9 @@ public static class NpcRuntimeSnapshotWorldApi
runtimeStore, runtimeStore,
threatStore, threatStore,
behaviorRegistry, behaviorRegistry,
abilityRegistry,
combatHealthStore,
positionStore,
playerHealthStore); playerHealthStore);
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry)); return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
}); });

View File

@ -1,5 +1,7 @@
using System.Collections.Frozen; using System.Collections.Frozen;
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Npc;
/// <summary> /// <summary>
@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules
return null; return null;
} }
/// <summary>
/// Returns a human-readable error when <paramref name="attackAbilityId"/> is missing from the ability catalog
/// or <c>attackDamage</c> does not match ability <c>baseDamage</c>, otherwise <see langword="null"/>.
/// </summary>
public static string? TryGetAttackAbilityCrossRefError(
IReadOnlyDictionary<string, NpcBehaviorDefRow> 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;
}
} }

View File

@ -82,7 +82,7 @@ On success, **Information** logs include the resolved recipes directory path, di
## Ability catalog (`content/abilities`, NEO-77) ## 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 | | Config | Meaning |
|--------|---------| |--------|---------|
@ -108,7 +108,7 @@ On success, **Information** logs include the resolved npc-behaviors directory pa
## NPC behavior definitions (NEO-90) ## 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 ```bash
curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions" 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 | | 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. | | **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. | | **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. | | **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)). | | **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. | | **`idle`** | No aggro holder; no telegraph. |
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. | | **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. | | **`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. | | **`recover`** | Post-attack cooldown wait before next windup. |
| Rule | Behavior | | 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" 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) ## 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 | | Rule | Behavior |
|------|----------| |------|----------|
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. | | **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). | | **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. | | **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |