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

View File

@ -49,6 +49,22 @@ tests {
expect(row.telegraphWindupSeconds).to.equal(1.5);
expect(row.attackDamage).to.equal(15);
expect(row.attackCooldownSeconds).to.equal(3);
expect(row.attackAbilityId).to.equal("prototype_npc_melee_strike");
});
test("prototype_ranged_control row matches catalog", function () {
const body = res.getBody();
const row = body.npcBehaviors.find((x) => x.id === "prototype_ranged_control");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Ranged Control");
expect(row.archetypeKind).to.equal("ranged_control");
expect(row.maxHp).to.equal(80);
expect(row.aggroRadius).to.equal(10);
expect(row.leashRadius).to.equal(20);
expect(row.telegraphWindupSeconds).to.equal(2);
expect(row.attackDamage).to.equal(12);
expect(row.attackCooldownSeconds).to.equal(4);
expect(row.attackAbilityId).to.equal("prototype_npc_ranged_shot");
});
test("prototype_elite_mini_boss row matches catalog", function () {
@ -63,5 +79,6 @@ tests {
expect(row.telegraphWindupSeconds).to.equal(2.5);
expect(row.attackDamage).to.equal(25);
expect(row.attackCooldownSeconds).to.equal(5);
expect(row.attackAbilityId).to.equal("prototype_npc_elite_slam");
});
}

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).
## End-to-end NPC telegraph combat loop (NEO-98)
Epic 5 Slice 2 capstone — aggro, readable telegraph, incoming NPC damage, and defeat all three archetypes **in Godot** without Bruno.
**Flow:** fresh server restart → boot hotbar sync → **Melee** lock → cast → telegraph HUD → player HP drop → defeat → repeat **Ranged** + **Elite**.
| Step | Input / trigger | HUD / server outcome |
|------|-----------------|----------------------|
| Boot | Godot **F5** + fresh server | Hotbar slot 0 binds **`prototype_pulse`**; player combat HP **100/100** when poll starts |
| Melee lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** **`Target HP: prototype_npc_melee 100/100`** |
| Melee aggro | **1** (damaging cast) | **`NpcStateLabel`** → **`aggro`**; combat poll ~1 Hz |
| Melee telegraph | Wait **≥ 3 s** | **`telegraph_windup`** + **`TelegraphLabel`** countdown |
| Melee hit | Wait **≥ 4.5 s** from first cast; stay **within ~2 m** of melee marker | **`PlayerCombatHpLabel`** → **`85/100`** (15 damage); back off during windup → whiff |
| Melee defeat | **1** ×4 total (cooldown between) | **`CombatTargetHpLabel`** includes **`0/100 (defeated)`** |
| Ranged | Lock + cast ×4; wait **≥ 4 s** for telegraph | Defeat at **80** HP; telegraph visible before damage |
| Elite | Lock + cast ×8; wait **≥ 5 s** for telegraph | Defeat at **200** HP; telegraph visible before damage |
**Cross-links:** [NEO-92](../docs/plans/NEO-92-implementation-plan.md) aggro · [NEO-94](../docs/plans/NEO-94-implementation-plan.md) runtime snapshot · [NEO-95](../docs/plans/NEO-95-implementation-plan.md) player combat HP · [NEO-97](../docs/manual-qa/NEO-97.md) telegraph HUD components · [NEO-85](../docs/manual-qa/NEO-85.md) combat-target HP · [NEO-28](../docs/manual-qa/NEO-28.md) cast deny HUD.
**Scripts:** `npc_runtime_client.gd`, `player_combat_health_client.gd`, `npc_runtime_hud_state.gd`, `npc_combat_hud_helpers.gd`, `combat_targets_client.gd`, `ability_cast_client.gd` — wired from `main.gd`.
**Preconditions:** **Server restart** before capstone run resets NPC HP, aggro/runtime rows, and player combat HP.
Full capstone checklist: [`docs/manual-qa/NEO-98.md`](../docs/manual-qa/NEO-98.md).
## End-to-end combat loop (NEO-86)
Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility **in Godot** without Bruno.

View File

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

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

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

View File

@ -14,7 +14,8 @@
"leashRadius",
"telegraphWindupSeconds",
"attackDamage",
"attackCooldownSeconds"
"attackCooldownSeconds",
"attackAbilityId"
],
"properties": {
"id": {
@ -40,7 +41,7 @@
"aggroRadius": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Horizontal proximity radius for re-aggro after leash clear (XZ-only at runtime)."
"description": "Horizontal proximity radius for future re-aggro (XZ-only at runtime; must be less than leashRadius). Distinct from ability maxRange."
},
"leashRadius": {
"type": "number",
@ -61,6 +62,11 @@
"type": "number",
"exclusiveMinimum": 0,
"description": "Seconds after last attack before the NPC may enter telegraph again."
},
"attackAbilityId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable ability id used at telegraph resolve for maxRange and baseDamage (must exist in ability catalog)."
}
}
}

View File

@ -7,8 +7,8 @@
| **Module ID** | E5.M2 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Status** | Ready — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) NPC attack resolve + player combat HP landed · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) NPC runtime telemetry hooks landed · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) client telegraph HUD landed · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) playable capstone landed |
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) · **E5M2-09** [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) · **E5M2-10** [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) · **E5M2-11** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) · **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
## Purpose
@ -67,11 +67,19 @@ Full story tables, kickoff defaults, and **`blockedBy` graph:** [E5M2-prototype-
The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.json` is **frozen** for behavior binding, aggro tuning, and telegraph timings until a deliberate migration issue expands the roster.
| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` |
|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|
| **`prototype_melee_pressure`** | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 |
| **`prototype_ranged_control`** | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 |
| **`prototype_elite_mini_boss`** | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 |
| `NpcBehaviorDef.id` | `displayName` | `archetypeKind` | `maxHp` | `aggroRadius` | `leashRadius` | `telegraphWindupSeconds` | `attackDamage` | `attackCooldownSeconds` | `attackAbilityId` |
|---------------------|---------------|-----------------|---------|---------------|---------------|--------------------------|----------------|-------------------------|-------------------|
| **`prototype_melee_pressure`** | Melee Pressure | `melee_pressure` | 100 | 8.0 | 16.0 | 1.5 | 15 | 3.0 | **`prototype_npc_melee_strike`** |
| **`prototype_ranged_control`** | Ranged Control | `ranged_control` | 80 | 10.0 | 20.0 | 2.0 | 12 | 4.0 | **`prototype_npc_ranged_shot`** |
| **`prototype_elite_mini_boss`** | Elite Mini-Boss | `elite_mini_boss` | 200 | 8.0 | 18.0 | 2.5 | 25 | 5.0 | **`prototype_npc_elite_slam`** |
**NPC attack abilities (NEO-98):** [`content/abilities/prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) — telegraph resolve uses ability **`maxRange`** + **`baseDamage`**, not behavior **`aggroRadius`**. Behavior **`attackDamage`** remains on the HTTP projection and must match ability **`baseDamage`** (CI cross-ref). Strike **`maxRange`** (horizontal X/Z): melee **2 m**, ranged **10 m**, elite **4 m**.
| Ability `id` | `baseDamage` | `maxRange` |
|--------------|--------------|------------|
| **`prototype_npc_melee_strike`** | 15 | 2.0 |
| **`prototype_npc_ranged_shot`** | 12 | 10.0 |
| **`prototype_npc_elite_slam`** | 25 | 4.0 |
**NPC instance ids (E5M2-05):** **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** — each binds one behavior def + world anchor (replaces **`prototype_target_alpha` / `beta`**).
@ -89,12 +97,14 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**NPC runtime snapshot HTTP (NEO-94):** **`GET /game/world/npc-runtime-snapshot`** — versioned read-only projection (`schemaVersion` **1**, **`npcInstances`**, optional nested **`activeTelegraph`**) with lazy **`AdvanceAll`** on poll. Plan: [NEO-94 implementation plan](../../plans/NEO-94-implementation-plan.md); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
**Session player combat HP (NEO-95):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`.
**Session player combat HP (NEO-95 / NEO-98):** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** in `server/NeonSprawl.Server/Game/Combat/` — telegraph complete looks up behavior **`attackAbilityId`**, applies ability catalog **`baseDamage`** when the aggro holder is within that ability's **`maxRange`** (horizontal X/Z); out-of-range windups whiff. Behavior row **`attackDamage`** is projection + CI cross-ref only (not the resolve source). **`TryStopOnTargetDefeat`** on cast accept + **`AdvanceOne`** defeated guard clears aggro/runtime when the NPC combat-target row is defeated (NEO-98). **`GET /game/players/{id}/combat-health`** read model for client HUD. Plan: [NEO-95 implementation plan](../../plans/NEO-95-implementation-plan.md); [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`.
**NPC runtime telemetry hooks (NEO-96):** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** (`TODO(E9.M1)`; no ingest). Plan: [NEO-96 implementation plan](../../plans/NEO-96-implementation-plan.md); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96).
**Client telegraph HUD (NEO-97):** Godot poll of **`npc-runtime-snapshot`** + **`combat-health`**; **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll gate. Plan: [NEO-97 implementation plan](../../plans/NEO-97-implementation-plan.md); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97); manual QA [NEO-97](../../manual-qa/NEO-97.md).
**Playable NPC telegraph combat capstone (NEO-98):** single-session Godot manual QA covering melee telegraph spine + three-archetype defeat — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). Plan: [NEO-98 implementation plan](../../plans/NEO-98-implementation-plan.md). **Server integration (capstone QA):** [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json), behavior **`attackAbilityId`**, range-gated telegraph resolve, defeat clears aggro/runtime. **Epic 5 Slice 2 client capstone complete.**
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
## Risks and telemetry

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.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

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**
- [ ] Human completes script with server + client; telegraph timing readable before damage.
- [ ] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA.
- [ ] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
- [x] Human completes script with server + client; telegraph timing readable before damage.
- [x] Epic 5 Slice 2 AC: telegraph timing matches UI feedback; aggro rules feel deterministic in manual QA.
- [x] All three archetypes defeated in one session; **`CombatTargetHpLabel`** shows defeated for each (manual QA steps 9, 14, 18).
- [x] Re-read [epic_05 Definition of Done](../decomposition/epics/epic_05_pve_combat.md#definition-of-done) for prototype minimums.
**Landed ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)):** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section; plan [NEO-98-implementation-plan.md](NEO-98-implementation-plan.md). **Epic 5 Slice 2 client capstone complete.**
**Client counterpart:** this issue (**NEO-98**).

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

View File

@ -652,6 +652,49 @@ public sealed class AbilityCastApiTests
Assert.Null(fifth.CombatResolution);
}
[Fact]
public async Task PostAbilityCast_ShouldStopNpcCombat_WhenTargetDefeated()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
var cast = PulseCastRequest();
for (var i = 0; i < 3; i++)
{
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
response.EnsureSuccessStatusCode();
factory.FakeClock.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
}
// Act
var fourthResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
fourthResponse.EnsureSuccessStatusCode();
var snapshotResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
factory.FakeClock.Advance(TimeSpan.FromSeconds(30));
_ = await client.GetAsync("/game/world/npc-runtime-snapshot");
var healthAfterWaitResponse = await client.GetAsync("/game/players/dev-local-1/combat-health");
// Assert
var fourth = await fourthResponse.Content.ReadFromJsonAsync<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]
public async Task PostAbilityCast_ShouldGrantGigXpOnceOnDefeat_WithoutChangingSkillProgression()
{

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,38 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class NpcAttackOperationsTests
{
private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z) =>
new(Options.Create(new GamePositionOptions
{
DevPlayerId = "dev-local-1",
DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z },
}));
[Fact]
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatches()
public void TryResolveTelegraphComplete_ShouldApplyCatalogDamage_WhenHolderMatchesAndInRange()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
@ -26,19 +41,71 @@ public sealed class NpcAttackOperationsTests
Assert.Equal(85, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderOutOfAbilityMaxRange()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
Assert.False(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderBeyondAbilityMaxRangeButInsideAggroRadius()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-10.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
Assert.False(ok);
playerHealthStore.TryGet("dev-local-1", out var snapshot);
Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp);
}
[Fact]
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderCleared()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
_ = threatStore.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
@ -51,14 +118,18 @@ public sealed class NpcAttackOperationsTests
public void TryResolveTelegraphComplete_ShouldNoOp_WhenHolderPlayerDiffers()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(-3.0, 0.5, -3.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "other-player");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 15,
PrototypeNpcAttackAbilityRegistry.PrototypeNpcMeleeStrike,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert
@ -68,17 +139,21 @@ public sealed class NpcAttackOperationsTests
}
[Fact]
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAttackDamageZero()
public void TryResolveTelegraphComplete_ShouldSucceedWithoutMutation_WhenAbilityBaseDamageZero()
{
// Arrange
var abilityRegistry = PrototypeNpcTestFixtures.CreateAbilityRegistry();
var threatStore = new InMemoryThreatStateStore();
var playerHealthStore = new InMemoryPlayerCombatHealthStore();
var positionStore = CreatePositionStoreAt(10.0, 0.5, 10.0);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var ok = NpcAttackOperations.TryResolveTelegraphComplete(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
attackDamage: 0,
PrototypeAbilityRegistry.PrototypeGuard,
abilityRegistry,
positionStore,
playerHealthStore,
threatStore);
// Assert

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -140,11 +140,12 @@ public static class AbilityDefinitionCatalogLoader
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
var maxRange = (rowObj["maxRange"] as JsonValue)!.GetValue<double>();
string? abilityKind = null;
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
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)

View File

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

View File

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

View File

@ -1,22 +1,32 @@
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
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
{
/// <summary>
/// Applies catalog <paramref name="attackDamage"/> to <paramref name="holderPlayerId"/> when
/// the threat row still names that holder for <paramref name="npcInstanceId"/>.
/// Applies catalog ability <c>baseDamage</c> to <paramref name="holderPlayerId"/> when the threat row still
/// 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>
public static bool TryResolveTelegraphComplete(
string npcInstanceId,
string holderPlayerId,
int attackDamage,
string attackAbilityId,
IAbilityDefinitionRegistry abilityRegistry,
IPositionStateStore positionStore,
IPlayerCombatHealthStore playerHealthStore,
IThreatStateStore threatStore)
{
if (attackDamage <= 0)
if (!abilityRegistry.TryGetDefinition(attackAbilityId, out var ability))
{
return false;
}
if (ability.BaseDamage <= 0)
{
return true;
}
@ -29,7 +39,12 @@ public static class NpcAttackOperations
return false;
}
if (!playerHealthStore.TryApplyDamage(holderPlayerId, attackDamage, out var snapshot))
if (!IsHolderWithinAbilityMaxRange(npcInstanceId, holderPlayerId, ability.MaxRange, positionStore))
{
return false;
}
if (!playerHealthStore.TryApplyDamage(holderPlayerId, ability.BaseDamage, out var snapshot))
{
return false;
}
@ -41,4 +56,25 @@ public static class NpcAttackOperations
return true;
}
internal static bool IsHolderWithinAbilityMaxRange(
string npcInstanceId,
string holderPlayerId,
double maxRange,
IPositionStateStore positionStore)
{
if (maxRange <= 0 ||
!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
!positionStore.TryGetPosition(holderPlayerId, out var playerSnap))
{
return false;
}
return HorizontalReach.IsWithinHorizontalRadius(
playerSnap.X,
playerSnap.Z,
entry.X,
entry.Z,
maxRange);
}
}

View File

@ -3,8 +3,9 @@ using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</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_E5M2_NPC_ATTACK_ABILITY_IDS</c>, and
/// <c>_prototype_ability_catalog_gate</c>.
/// </summary>
public static class PrototypeE5M1AbilityCatalogRules
{
@ -18,15 +19,29 @@ public static class PrototypeE5M1AbilityCatalogRules
],
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)
{
var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedAbilityIds))
if (!ids.SetEquals(ExpectedCatalogAbilityIds))
{
return
"error: prototype E5M1 expects exactly ability ids " +
$"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
"error: prototype ability catalog expects exactly ability ids " +
$"[{string.Join(", ", ExpectedCatalogAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}

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

View File

@ -10,4 +10,5 @@ public sealed record NpcBehaviorDefRow(
double LeashRadius,
double TelegraphWindupSeconds,
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 attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
var attackAbilityId = (rowObj["attackAbilityId"] as JsonValue)!.GetValue<string>();
return new NpcBehaviorDefRow(
id,
@ -162,7 +163,8 @@ public static class NpcBehaviorDefinitionCatalogLoader
leashRadius,
telegraphWindupSeconds,
attackDamage,
attackCooldownSeconds);
attackCooldownSeconds,
attackAbilityId);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)

View File

@ -44,4 +44,7 @@ public sealed class NpcBehaviorDefinitionJson
[JsonPropertyName("attackCooldownSeconds")]
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,
AttackDamage = d.AttackDamage,
AttackCooldownSeconds = d.AttackCooldownSeconds,
AttackAbilityId = d.AttackAbilityId,
});
}

View File

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

View File

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

View File

@ -1,5 +1,7 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
@ -47,4 +49,30 @@ public static class PrototypeE5M2NpcBehaviorCatalogRules
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)
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype ability catalog** seven-id roster gate (four player hotbar abilities plus three NPC attack abilities; same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
| Config | Meaning |
|--------|---------|
@ -108,7 +108,7 @@ On success, **Information** logs include the resolved npc-behaviors directory pa
## NPC behavior definitions (NEO-90)
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, and **`attackCooldownSeconds`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions"
@ -161,7 +161,9 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In
| Rule | Behavior |
|------|----------|
| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. |
| **Attack range** | At telegraph resolve, damage applies only when the holder is within the bound attack ability's catalog **`maxRange`** of the NPC anchor (horizontal X/Z, inclusive). Melee strike **2 m**, ranged shot **10 m**, elite slam **4 m** — distinct from behavior **`aggroRadius`** (re-aggro) and tab-lock **6 m**. Out-of-range windups whiff (no damage; state still advances). |
| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. |
| **Defeat clear** | When **`ICombatEntityHealthStore`** marks the NPC **`defeated`**, **`NpcRuntimeOperations.TryStopOnTargetDefeat`** clears holder and resets runtime to **`idle`** (cast accept + lazy advance guard). |
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
@ -176,7 +178,7 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** +
| **`idle`** | No aggro holder; no telegraph. |
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. |
| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies catalog **`attackDamage`** to aggro holder ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). |
| **`attack_execute`** | Logical instant during telegraph complete; **`NpcAttackOperations.TryResolveTelegraphComplete`** applies the behavior's **`attackAbilityId`** **`baseDamage`** when holder is within that ability's **`maxRange`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). |
| **`recover`** | Post-attack cooldown wait before next windup. |
| Rule | Behavior |
@ -208,7 +210,7 @@ Comment-only hook sites in **`NpcRuntimeOperations.AdvanceAll`** for future E9.M
curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot"
```
**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, catalog **`attackDamage`** applies to the aggro holder via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers). When windup completes during **`AdvanceAll`**, the behavior's **`attackAbilityId`** **`baseDamage`** applies to the aggro holder when within that ability's **`maxRange`** via **`IPlayerCombatHealthStore`** ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
## Session player combat HP (NEO-95)
@ -217,7 +219,7 @@ Session player HP for incoming NPC damage lives in **`Game/Combat/`** as **`IPla
| Rule | Behavior |
|------|----------|
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`**; starts at **100** HP. |
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes and aggro holder still matches. |
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Applied from **`NpcAttackOperations.TryResolveTelegraphComplete`** when telegraph windup completes, aggro holder still matches, and holder is within the attack ability's **`maxRange`** of the NPC anchor. |
| **Holder re-check** | No damage when holder cleared during windup (leash clear). |
| **Fixture reset** | Dev combat-target fixture restores dev player to full HP alongside NPC HP + threat + runtime reset. |