From fa9acc85ace2aadb4292253109a7f5e7212722d1 Mon Sep 17 00:00:00 2001 From: Don Date: Tue, 26 May 2026 23:42:31 -0400 Subject: [PATCH] NEO-91: migrate combat targets to three NPC instances Replace alpha/beta dummies with PrototypeNpcRegistry (melee/ranged/elite), per-archetype max HP from INpcBehaviorDefinitionRegistry, and updated server tests + Bruno smokes. Documents breaking client change for NEO-97. Co-authored-by: Cursor --- .../ability-cast/Post cast happy.bru | 6 +- .../Post cast lock pulse defeat spine.bru | 6 +- .../Post move reset dev spawn before cast.bru | 2 +- .../Post target select alpha before cast.bru | 4 +- .../Get combat targets after cast spine.bru | 32 ++--- .../Get combat targets after one cast.bru | 31 ++--- .../combat-targets/Get combat targets.bru | 26 +++-- ...Get gig progression after defeat spine.bru | 4 +- .../targeting/Post target select alpha.bru | 4 +- .../Post target select beta with hint.bru | 4 +- .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E5M2-prototype-backlog.md | 8 +- docs/plans/NEO-91-implementation-plan.md | 16 +-- .../Game/AbilityInput/AbilityCastApiTests.cs | 25 ++-- .../AbilityInput/CooldownSnapshotApiTests.cs | 5 +- .../Combat/CombatEntityHealthStoreTests.cs | 110 +++++++++++------- .../Game/Combat/CombatOperationsTests.cs | 55 ++++----- .../Combat/CombatTargetFixtureApiTests.cs | 11 +- .../Game/Combat/CombatTargetsWorldApiTests.cs | 75 ++++++------ ...ressionGrantPersistenceIntegrationTests.cs | 5 +- .../Game/Npc/PrototypeNpcRegistryTests.cs | 86 ++++++++++++++ .../Game/Npc/PrototypeNpcTestFixtures.cs | 51 ++++++++ .../Targeting/PlayerTargetStateReaderTests.cs | 20 ++-- .../Game/Targeting/TargetingApiTests.cs | 37 +++--- .../Game/AbilityInput/AbilityCastApi.cs | 3 +- .../Game/Combat/CombatTargetFixtureApi.cs | 4 +- .../Game/Combat/CombatTargetsWorldApi.cs | 4 +- .../Game/Combat/ICombatEntityHealthStore.cs | 8 +- .../Combat/InMemoryCombatEntityHealthStore.cs | 49 +++++--- .../Game/Combat/PrototypeCombatConstants.cs | 11 -- .../Game/Npc/PrototypeNpcRegistry.cs | 71 +++++++++++ .../Game/Targeting/PlayerTargetStateReader.cs | 3 +- .../Game/Targeting/PrototypeTargetRegistry.cs | 44 ------- .../Game/Targeting/TargetingApi.cs | 3 +- server/README.md | 30 ++--- 36 files changed, 540 insertions(+), 317 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcRegistryTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs delete mode 100644 server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs delete mode 100644 server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru index d238b66..bda1a14 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast happy.bru @@ -27,7 +27,7 @@ script:pre-request { await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, - { schemaVersion: 1, targetId: "prototype_target_beta" }, + { schemaVersion: 1, targetId: "prototype_npc_ranged" }, jsonHeaders, ); } @@ -43,7 +43,7 @@ body:json { "schemaVersion": 1, "slotIndex": 0, "abilityId": "prototype_pulse", - "targetId": "prototype_target_beta" + "targetId": "prototype_npc_ranged" } } @@ -60,7 +60,7 @@ tests { expect(body.accepted).to.equal(true); expect(body.combatResolution).to.be.an("object"); expect(body.combatResolution.abilityId).to.equal("prototype_pulse"); - expect(body.combatResolution.targetId).to.equal("prototype_target_beta"); + expect(body.combatResolution.targetId).to.equal("prototype_npc_ranged"); expect(body.combatResolution.damageDealt).to.equal(25); expect(body.combatResolution.targetRemainingHp).to.equal(75); expect(body.combatResolution.targetDefeated).to.equal(false); diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru index c4db35c..2f59992 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post cast lock pulse defeat spine.bru @@ -35,7 +35,7 @@ script:pre-request { await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, - { schemaVersion: 1, targetId: "prototype_target_alpha" }, + { schemaVersion: 1, targetId: "prototype_npc_melee" }, jsonHeaders, ); @@ -43,7 +43,7 @@ script:pre-request { schemaVersion: 1, slotIndex: 1, abilityId: "prototype_pulse", - targetId: "prototype_target_alpha", + targetId: "prototype_npc_melee", }; const castResults = []; @@ -80,7 +80,7 @@ body:json { "schemaVersion": 1, "slotIndex": 1, "abilityId": "prototype_pulse", - "targetId": "prototype_target_alpha" + "targetId": "prototype_npc_melee" } } diff --git a/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru b/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru index b0f5d8d..afb4c7e 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post move reset dev spawn before cast.bru @@ -5,7 +5,7 @@ meta { } docs { - Resets authoritative position to `Game:DefaultPosition` (~-5, 0.9, -5) so `prototype_target_alpha` lock and cast range checks pass when the collection ran earlier requests that moved the player (e.g. seq 10–11 resource-node gather). Runs before hotbar/target/cast (seq 20–22). + Resets authoritative position to `Game:DefaultPosition` (~-5, 0.9, -5) so `prototype_npc_melee` lock and cast range checks pass when the collection ran earlier requests that moved the player (e.g. seq 10–11 resource-node gather). Runs before hotbar/target/cast (seq 20–22). } post { diff --git a/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru b/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru index 4fc3aba..a46e22f 100644 --- a/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru +++ b/bruno/neon-sprawl-server/ability-cast/Post target select alpha before cast.bru @@ -27,7 +27,7 @@ post { body:json { { "schemaVersion": 1, - "targetId": "prototype_target_alpha" + "targetId": "prototype_npc_melee" } } @@ -36,6 +36,6 @@ tests { expect(res.getStatus()).to.equal(200); const body = res.getBody(); expect(body.selectionApplied).to.equal(true); - expect(body.targetState.lockedTargetId).to.equal("prototype_target_alpha"); + expect(body.targetState.lockedTargetId).to.equal("prototype_npc_melee"); }); } diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru index dc93f44..9da0ead 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after cast spine.bru @@ -38,7 +38,7 @@ script:pre-request { await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, - { schemaVersion: 1, targetId: "prototype_target_alpha" }, + { schemaVersion: 1, targetId: "prototype_npc_melee" }, jsonHeaders, ); @@ -46,7 +46,7 @@ script:pre-request { schemaVersion: 1, slotIndex: 1, abilityId: "prototype_pulse", - targetId: "prototype_target_alpha", + targetId: "prototype_npc_melee", }; const castResults = []; @@ -82,7 +82,7 @@ tests { expect(res.getStatus()).to.equal(200); const body = res.getBody(); expect(body.schemaVersion).to.equal(1); - expect(body.targets.length).to.equal(2); + expect(body.targets.length).to.equal(3); }); test("cast results stepped HP before GET", function () { @@ -95,24 +95,24 @@ tests { expect(castResults[3].combatResolution.targetDefeated).to.equal(true); }); - test("alpha reflects cast damage authoritatively", function () { + test("melee reflects cast damage authoritatively", function () { const body = res.getBody(); - const alpha = body.targets.find( - (x) => x.targetId === "prototype_target_alpha", + const melee = body.targets.find( + (x) => x.targetId === "prototype_npc_melee", ); - expect(alpha).to.be.an("object"); - expect(alpha.currentHp).to.equal(0); - expect(alpha.maxHp).to.equal(100); - expect(alpha.defeated).to.equal(true); + expect(melee).to.be.an("object"); + expect(melee.currentHp).to.equal(0); + expect(melee.maxHp).to.equal(100); + expect(melee.defeated).to.equal(true); }); - test("beta unchanged after alpha-only casts", function () { + test("ranged unchanged after melee-only casts", function () { const body = res.getBody(); - const beta = body.targets.find( - (x) => x.targetId === "prototype_target_beta", + const ranged = body.targets.find( + (x) => x.targetId === "prototype_npc_ranged", ); - expect(beta).to.be.an("object"); - expect(beta.currentHp).to.equal(100); - expect(beta.defeated).to.equal(false); + expect(ranged).to.be.an("object"); + expect(ranged.currentHp).to.equal(80); + expect(ranged.defeated).to.equal(false); }); } diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru index 77578fa..c810388 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets after one cast.bru @@ -34,7 +34,7 @@ script:pre-request { await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, - { schemaVersion: 1, targetId: "prototype_target_alpha" }, + { schemaVersion: 1, targetId: "prototype_npc_melee" }, jsonHeaders, ); @@ -44,7 +44,7 @@ script:pre-request { schemaVersion: 1, slotIndex: 0, abilityId: "prototype_pulse", - targetId: "prototype_target_alpha", + targetId: "prototype_npc_melee", }, jsonHeaders, ); @@ -70,24 +70,25 @@ tests { expect(castBody.combatResolution.targetDefeated).to.equal(false); }); - test("GET alpha reflects cast damage authoritatively", function () { + test("GET melee reflects cast damage authoritatively", function () { const body = res.getBody(); - const alpha = body.targets.find( - (x) => x.targetId === "prototype_target_alpha", + const melee = body.targets.find( + (x) => x.targetId === "prototype_npc_melee", ); - expect(alpha).to.be.an("object"); - expect(alpha.currentHp).to.equal(75); - expect(alpha.maxHp).to.equal(100); - expect(alpha.defeated).to.equal(false); + expect(melee).to.be.an("object"); + expect(melee.currentHp).to.equal(75); + expect(melee.maxHp).to.equal(100); + expect(melee.defeated).to.equal(false); }); - test("beta unchanged after alpha-only cast", function () { + test("ranged unchanged after melee-only cast", function () { const body = res.getBody(); - const beta = body.targets.find( - (x) => x.targetId === "prototype_target_beta", + const ranged = body.targets.find( + (x) => x.targetId === "prototype_npc_ranged", ); - expect(beta).to.be.an("object"); - expect(beta.currentHp).to.equal(100); - expect(beta.defeated).to.equal(false); + expect(ranged).to.be.an("object"); + expect(ranged.currentHp).to.equal(80); + expect(ranged.maxHp).to.equal(80); + expect(ranged.defeated).to.equal(false); }); } diff --git a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru index aaf7080..54ef49f 100644 --- a/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru +++ b/bruno/neon-sprawl-server/combat-targets/Get combat targets.bru @@ -22,7 +22,7 @@ tests { const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.targets).to.be.an("array"); - expect(body.targets.length).to.equal(2); + expect(body.targets.length).to.equal(3); }); test("targets are ascending by targetId (ordinal)", function () { @@ -32,21 +32,27 @@ tests { expect(ids).to.eql(sorted); }); - test("prototype registry targets match id order", function () { + test("prototype NPC registry targets match id order", function () { const body = res.getBody(); const ids = body.targets.map((x) => x.targetId); expect(ids).to.eql([ - "prototype_target_alpha", - "prototype_target_beta", + "prototype_npc_elite", + "prototype_npc_melee", + "prototype_npc_ranged", ]); }); - test("both targets start at full HP on fresh server", function () { + test("all targets start at catalog max HP on fresh server", function () { const body = res.getBody(); - for (const row of body.targets) { - expect(row.maxHp).to.equal(100); - expect(row.currentHp).to.equal(100); - expect(row.defeated).to.equal(false); - } + const byId = Object.fromEntries(body.targets.map((x) => [x.targetId, x])); + expect(byId.prototype_npc_elite.maxHp).to.equal(200); + expect(byId.prototype_npc_elite.currentHp).to.equal(200); + expect(byId.prototype_npc_elite.defeated).to.equal(false); + expect(byId.prototype_npc_melee.maxHp).to.equal(100); + expect(byId.prototype_npc_melee.currentHp).to.equal(100); + expect(byId.prototype_npc_melee.defeated).to.equal(false); + expect(byId.prototype_npc_ranged.maxHp).to.equal(80); + expect(byId.prototype_npc_ranged.currentHp).to.equal(80); + expect(byId.prototype_npc_ranged.defeated).to.equal(false); }); } diff --git a/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru b/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru index f5905a4..1286e35 100644 --- a/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru +++ b/bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru @@ -56,7 +56,7 @@ script:pre-request { await axios.post( `${baseUrl}/game/players/${playerId}/target/select`, - { schemaVersion: 1, targetId: "prototype_target_alpha" }, + { schemaVersion: 1, targetId: "prototype_npc_melee" }, jsonHeaders, ); @@ -64,7 +64,7 @@ script:pre-request { schemaVersion: 1, slotIndex: 2, abilityId: "prototype_pulse", - targetId: "prototype_target_alpha", + targetId: "prototype_npc_melee", }; for (let i = 0; i < 4; i++) { diff --git a/bruno/neon-sprawl-server/targeting/Post target select alpha.bru b/bruno/neon-sprawl-server/targeting/Post target select alpha.bru index 945ba25..bd5385c 100644 --- a/bruno/neon-sprawl-server/targeting/Post target select alpha.bru +++ b/bruno/neon-sprawl-server/targeting/Post target select alpha.bru @@ -1,5 +1,5 @@ meta { - name: POST target select - prototype_target_alpha + name: POST target select - prototype_npc_melee type: http seq: 11 } @@ -17,6 +17,6 @@ headers { body:json { { "schemaVersion": 1, - "targetId": "prototype_target_alpha" + "targetId": "prototype_npc_melee" } } diff --git a/bruno/neon-sprawl-server/targeting/Post target select beta with hint.bru b/bruno/neon-sprawl-server/targeting/Post target select beta with hint.bru index 88b9361..d7e51fb 100644 --- a/bruno/neon-sprawl-server/targeting/Post target select beta with hint.bru +++ b/bruno/neon-sprawl-server/targeting/Post target select beta with hint.bru @@ -1,5 +1,5 @@ meta { - name: POST target select - prototype_target_beta (with positionHint) + name: POST target select - prototype_npc_ranged (with positionHint) type: http seq: 13 } @@ -17,7 +17,7 @@ headers { body:json { { "schemaVersion": 1, - "targetId": "prototype_target_beta", + "targetId": "prototype_npc_ranged", "positionHint": { "x": 3.0, "y": 0.5, "z": 5.0 } } } diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index fc2b81c..4b96b85 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -81,6 +81,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **NPC behavior definition registry (NEO-89):** **`INpcBehaviorDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Npc/` wraps the startup-loaded catalog for read-only lookup by stable behavior `id` (`TryGetDefinition`), trim/lowercase validation (`TryNormalizeKnown`), and ordered enumeration (`GetDefinitionsInIdOrder`). Stable prototype id constants: **`PrototypeNpcBehaviorRegistry`**. **NEO-91+** (instance registry, runtime tick) should inject the interface rather than `NpcBehaviorDefinitionCatalog`. Plan: [NEO-89 implementation plan](../../plans/NEO-89-implementation-plan.md). +**NPC instance registry (NEO-91):** **`PrototypeNpcRegistry`** in `server/NeonSprawl.Server/Game/Npc/` — instance ids **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** with behavior bindings + world anchors; **`ICombatEntityHealthStore`** uses catalog **`maxHp`** per instance. Plan: [NEO-91 implementation plan](../../plans/NEO-91-implementation-plan.md). **Breaking:** Godot constants migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97). + **NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. ## Risks and telemetry diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 954fc6f..d2c4542 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -57,7 +57,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | -| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 7ec6022..a6037d8 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -190,9 +190,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **Acceptance criteria** -- [ ] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed). -- [ ] Each instance references a valid frozen `behaviorDefId`. -- [ ] Bruno defeat spine passes for at least one archetype. +- [x] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed). +- [x] Each instance references a valid frozen `behaviorDefId`. +- [x] Bruno defeat spine passes for at least one archetype. + +**Landed ([NEO-91](https://linear.app/neon-sprawl/issue/NEO-91)):** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog; plan [NEO-91-implementation-plan.md](NEO-91-implementation-plan.md). **Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — archetype markers + telegraph HUD (after runtime snapshot lands). diff --git a/docs/plans/NEO-91-implementation-plan.md b/docs/plans/NEO-91-implementation-plan.md index 2f2acef..93af4d6 100644 --- a/docs/plans/NEO-91-implementation-plan.md +++ b/docs/plans/NEO-91-implementation-plan.md @@ -42,9 +42,9 @@ ## Acceptance criteria checklist -- [ ] Cast + combat-targets GET operate on **three NPC ids only** (alpha/beta removed). -- [ ] Each instance references a valid frozen **`behaviorDefId`** resolvable via **`INpcBehaviorDefinitionRegistry`**. -- [ ] Bruno defeat spine passes for **`prototype_npc_melee`** (4× pulse → defeated). +- [x] Cast + combat-targets GET operate on **three NPC ids only** (alpha/beta removed). +- [x] Each instance references a valid frozen **`behaviorDefId`** resolvable via **`INpcBehaviorDefinitionRegistry`**. +- [x] Bruno defeat spine passes for **`prototype_npc_melee`** (4× pulse → defeated). ## Technical approach @@ -154,9 +154,9 @@ Bruno **`combat-targets/Get combat targets after cast spine.bru`** is the AC def | **Gig XP defeat grant** | No change — grant fires on any target defeat; melee spine still satisfies NEO-44 chain. | **adopted** | | **Elite HP in future spines** | Defer 8× pulse Bruno until needed; melee spine satisfies AC. | **deferred** | -## Decisions (kickoff) +## Reconciliation (implementation) -- **`PrototypeNpcRegistry`** in `Game/Npc/` — new source of truth; retire alpha/beta (user confirmed). -- **Anchors:** melee `(-3, 0.5, -3)`, ranged `(3, 0, 3)`, elite `(0, 0.5, 0)`; lock radius **6.0** (user confirmed). -- **Behavior binding:** 1:1 melee/ranged/elite → frozen behavior ids (adopted). -- **Bruno defeat spine:** **`prototype_npc_melee`** (user confirmed). +- **`PrototypeNpcRegistry`** landed in `Game/Npc/`; **`PrototypeTargetRegistry`** and **`PrototypeCombatConstants`** removed. +- **`InMemoryCombatEntityHealthStore`** injects **`INpcBehaviorDefinitionRegistry`** for per-instance catalog max HP. +- Server tests + Bruno migrated to three NPC ids; melee 4× pulse defeat spine is AC verification. +- **Client not updated** — [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) owns `prototype_target_constants.gd` migration. diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index bbb8685..f61a95a 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; using Xunit; @@ -39,7 +40,7 @@ public sealed class AbilityCastApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); @@ -65,7 +66,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) @@ -97,7 +98,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -108,7 +109,7 @@ public sealed class AbilityCastApiTests Assert.Null(body.ReasonCode); Assert.NotNull(body.CombatResolution); Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.CombatResolution.TargetId); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.CombatResolution.TargetId); Assert.Equal(25, body.CombatResolution.DamageDealt); Assert.Equal(75, body.CombatResolution.TargetRemainingHp); Assert.False(body.CombatResolution.TargetDefeated); @@ -131,7 +132,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = "prototype_target_alpha", + TargetId = " Prototype_Npc_Melee ", }); // Assert @@ -214,7 +215,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId, }); // Assert @@ -241,7 +242,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -270,7 +271,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -485,7 +486,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act @@ -518,7 +519,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act @@ -552,7 +553,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act @@ -691,7 +692,7 @@ public sealed class AbilityCastApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypeGuard, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs index c190225..049d4ad 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Tests; using Xunit; @@ -28,7 +29,7 @@ public sealed class CooldownSnapshotApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); response.EnsureSuccessStatusCode(); } @@ -81,7 +82,7 @@ public sealed class CooldownSnapshotApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs index 25fd6d2..4af5c53 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs @@ -1,7 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Combat; -using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Tests; +using NeonSprawl.Server.Tests.Game.Npc; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; @@ -9,17 +10,17 @@ namespace NeonSprawl.Server.Tests.Game.Combat; public sealed class CombatEntityHealthStoreTests { [Fact] - public void TryGet_OnFirstAccess_ShouldLazyInitAlphaToFullHp() + public void TryGet_OnFirstAccess_ShouldLazyInitMeleeToCatalogMaxHp() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act - var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert Assert.True(found); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.MaxHp); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.TargetId); + Assert.Equal(100, snapshot.MaxHp); + Assert.Equal(100, snapshot.CurrentHp); Assert.False(snapshot.Defeated); } @@ -27,9 +28,9 @@ public sealed class CombatEntityHealthStoreTests public void TryApplyDamage_ShouldReduceHp_WhenDamageIsApplied() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act - var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot); + var applied = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var snapshot); // Assert Assert.True(applied); Assert.Equal(75, snapshot.CurrentHp); @@ -40,12 +41,12 @@ public sealed class CombatEntityHealthStoreTests public void TryApplyDamage_RepeatedUntilZero_ShouldMarkDefeated() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act - var first = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); - var second = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); - var third = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); - var fourth = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var defeated); + var first = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _); + var second = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _); + var third = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _); + var fourth = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var defeated); // Assert Assert.True(first); Assert.True(second); @@ -59,10 +60,10 @@ public sealed class CombatEntityHealthStoreTests public void TryApplyDamage_OnDefeatedTarget_ShouldKeepHpAtZeroAndRemainDefeated() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); - _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); + _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act - var overkill = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot); + var overkill = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var snapshot); // Assert Assert.True(overkill); Assert.Equal(0, snapshot.CurrentHp); @@ -73,36 +74,51 @@ public sealed class CombatEntityHealthStoreTests public void TryResetToFull_AfterDefeat_ShouldRestoreFullHpAndClearDefeated() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); - _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); + _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act - var reset = store.TryResetToFull(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + var reset = store.TryResetToFull(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert Assert.True(reset); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.Equal(100, snapshot.CurrentHp); Assert.False(snapshot.Defeated); } [Fact] - public void TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha() + public void TryGet_OnRanged_ShouldLazyInitIndependentlyFromMelee() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); - _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out var alphaAfterDamage); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); + _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 40, out var meleeAfterDamage); // Act - var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetBetaId, out var beta); + var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcRangedId, out var ranged); // Assert Assert.True(found); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); - Assert.False(beta.Defeated); - Assert.Equal(60, alphaAfterDamage.CurrentHp); + Assert.Equal(80, ranged.MaxHp); + Assert.Equal(80, ranged.CurrentHp); + Assert.False(ranged.Defeated); + Assert.Equal(60, meleeAfterDamage.CurrentHp); + } + + [Fact] + public void TryGet_OnElite_ShouldLazyInitToTwoHundredMaxHp() + { + // Arrange + var store = PrototypeNpcTestFixtures.CreateHealthStore(); + // Act + var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcEliteId, out var elite); + // Assert + Assert.True(found); + Assert.Equal(200, elite.MaxHp); + Assert.Equal(200, elite.CurrentHp); + Assert.False(elite.Defeated); } [Fact] public void TryGet_ShouldReturnFalse_WhenTargetIdIsUnknown() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var found = store.TryGet("not_a_prototype_target", out var snapshot); // Assert @@ -114,7 +130,7 @@ public sealed class CombatEntityHealthStoreTests public void TryGet_ShouldReturnFalse_WhenTargetIdIsEmpty() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var found = store.TryGet(" ", out var snapshot); // Assert @@ -126,9 +142,9 @@ public sealed class CombatEntityHealthStoreTests public void TryApplyDamage_ShouldReturnFalse_WhenDamageIsNegative() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act - var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, -1, out var snapshot); + var applied = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, -1, out var snapshot); // Assert Assert.False(applied); Assert.Equal(default, snapshot); @@ -138,7 +154,7 @@ public sealed class CombatEntityHealthStoreTests public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsUnknown() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var applied = store.TryApplyDamage("not_a_prototype_target", 25, out var snapshot); // Assert @@ -150,7 +166,7 @@ public sealed class CombatEntityHealthStoreTests public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsEmpty() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var applied = store.TryApplyDamage(" ", 25, out var snapshot); // Assert @@ -162,20 +178,32 @@ public sealed class CombatEntityHealthStoreTests public void TryGet_ShouldNormalizeCaseVariantTargetIds() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act - var found = store.TryGet(" Prototype_Target_Alpha ", out var snapshot); + var found = store.TryGet(" Prototype_Npc_Melee ", out var snapshot); // Assert Assert.True(found); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.TargetId); + Assert.Equal(100, snapshot.CurrentHp); + } + + [Fact] + public void TryGet_ShouldReturnFalse_ForLegacyAlphaDummyId() + { + // Arrange + var store = PrototypeNpcTestFixtures.CreateHealthStore(); + // Act + var found = store.TryGet("prototype_target_alpha", out var snapshot); + // Assert + Assert.False(found); + Assert.Equal(default, snapshot); } [Fact] public void TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown() { // Arrange - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var reset = store.TryResetToFull("not_a_prototype_target", out var snapshot); // Assert @@ -192,10 +220,10 @@ public sealed class CombatEntityHealthStoreTests _ = await client.GetAsync("/health"); // Act var store = factory.Services.GetRequiredService(); - var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert Assert.True(found); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.Equal(100, snapshot.CurrentHp); Assert.False(snapshot.Defeated); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs index 069af32..3be602d 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs @@ -1,8 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; -using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Tests; +using NeonSprawl.Server.Tests.Game.Npc; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; @@ -47,11 +48,11 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); // Assert @@ -67,12 +68,12 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); for (var i = 0; i < 3; i++) { _ = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); } @@ -80,7 +81,7 @@ public sealed class CombatOperationsTests // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); @@ -95,12 +96,12 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); for (var i = 0; i < 4; i++) { _ = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); } @@ -108,10 +109,10 @@ public sealed class CombatOperationsTests // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); - store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert Assert.False(result.Success); @@ -128,22 +129,22 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypeGuard, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); - store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); + store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Equal(0, result.DamageDealt); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp); + Assert.Equal(100, result.TargetRemainingHp); Assert.False(result.TargetDefeated); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); + Assert.Equal(100, snapshot.CurrentHp); } [Fact] @@ -151,12 +152,12 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); - _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); + _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypeGuard, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); // Assert @@ -172,18 +173,18 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypeDash, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); // Assert Assert.True(result.Success); Assert.Null(result.ReasonCode); Assert.Equal(0, result.DamageDealt); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp); + Assert.Equal(100, result.TargetRemainingHp); Assert.False(result.TargetDefeated); } @@ -192,11 +193,11 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var result = CombatOperations.TryResolve( "not_a_real_ability", - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); // Assert @@ -211,7 +212,7 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, @@ -230,11 +231,11 @@ public sealed class CombatOperationsTests { // Arrange var registry = CreatePrototypeRegistry(); - var store = new InMemoryCombatEntityHealthStore(); + var store = PrototypeNpcTestFixtures.CreateHealthStore(); // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypeBurst, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); // Assert @@ -256,7 +257,7 @@ public sealed class CombatOperationsTests // Act var result = CombatOperations.TryResolve( PrototypeAbilityRegistry.PrototypePulse, - PrototypeTargetRegistry.PrototypeTargetAlphaId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, registry, store); // Assert diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index 2e89d94..f3d748f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -3,7 +3,8 @@ using System.Net.Http.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Combat; -using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Tests; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; @@ -19,7 +20,7 @@ public sealed class CombatTargetFixtureApiTests await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var store = factory.Services.GetRequiredService(); - _ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); + _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act var response = await client.PostAsJsonAsync( @@ -34,9 +35,9 @@ public sealed class CombatTargetFixtureApiTests var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body!.Applied); - store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp); - Assert.False(alpha.Defeated); + store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee); + Assert.Equal(100, melee.CurrentHp); + Assert.False(melee.Defeated); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs index 5509ef7..c3549bb 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs @@ -3,20 +3,16 @@ using System.Net; using System.Net.Http.Json; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Tests; +using NeonSprawl.Server.Tests.Game.Npc; using Xunit; namespace NeonSprawl.Server.Tests.Game.Combat; public class CombatTargetsWorldApiTests { - /// Prototype combat targets in registry id order (ordinal). Keep in sync with Bruno. - public static readonly string[] PrototypeTargetsInIdOrder = - [ - PrototypeTargetRegistry.PrototypeTargetAlphaId, - PrototypeTargetRegistry.PrototypeTargetBetaId, - ]; - private static async Task BindSlot0PulseAsync(HttpClient client) { var post = await client.PostAsJsonAsync( @@ -29,14 +25,14 @@ public class CombatTargetsWorldApiTests post.EnsureSuccessStatusCode(); } - private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) + private static async Task LockPrototypeNpcMeleeAsync(HttpClient client) { var response = await client.PostAsJsonAsync( "/game/players/dev-local-1/target/select", new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadFromJsonAsync(); @@ -50,11 +46,11 @@ public class CombatTargetsWorldApiTests SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; [Fact] - public async Task GetCombatTargets_ShouldReturnSchemaV1_WithBothTargetsAtFullHp() + public async Task GetCombatTargets_ShouldReturnSchemaV1_WithThreeTargetsAtFullHp() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); @@ -67,29 +63,34 @@ public class CombatTargetsWorldApiTests Assert.NotNull(body); Assert.Equal(CombatTargetsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.NotNull(body.Targets); - Assert.Equal(2, body.Targets.Count); + Assert.Equal(3, body.Targets.Count); var ids = body.Targets.Select(static t => t.TargetId).ToList(); - Assert.Equal(PrototypeTargetsInIdOrder, ids); + Assert.Equal(PrototypeNpcRegistryTests.InstancesInIdOrder, ids); - var alpha = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.MaxHp); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp); - Assert.False(alpha.Defeated); + var elite = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcEliteId); + Assert.Equal(200, elite.MaxHp); + Assert.Equal(200, elite.CurrentHp); + Assert.False(elite.Defeated); - var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.MaxHp); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); - Assert.False(beta.Defeated); + var melee = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId); + Assert.Equal(100, melee.MaxHp); + Assert.Equal(100, melee.CurrentHp); + Assert.False(melee.Defeated); + + var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId); + Assert.Equal(80, ranged.MaxHp); + Assert.Equal(80, ranged.CurrentHp); + Assert.False(ranged.Defeated); } [Fact] - public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha() + public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnMelee() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); await BindSlot0PulseAsync(client); - await LockPrototypeTargetAlphaAsync(client); + await LockPrototypeNpcMeleeAsync(client); var castResponse = await client.PostAsJsonAsync( "/game/players/dev-local-1/ability-cast", PulseCastRequest()); @@ -107,23 +108,23 @@ public class CombatTargetsWorldApiTests Assert.Equal(75, castBody.CombatResolution!.TargetRemainingHp); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotNull(body); - var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); - Assert.Equal(75, alpha.CurrentHp); - Assert.False(alpha.Defeated); - var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); - Assert.False(beta.Defeated); + var melee = body!.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId); + Assert.Equal(75, melee.CurrentHp); + Assert.False(melee.Defeated); + var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId); + Assert.Equal(80, ranged.CurrentHp); + Assert.False(ranged.Defeated); } [Fact] - public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnAlpha() + public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnMelee() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); Assert.NotNull(factory.FakeClock); await BindSlot0PulseAsync(client); - await LockPrototypeTargetAlphaAsync(client); + await LockPrototypeNpcMeleeAsync(client); var cast = PulseCastRequest(); for (var i = 0; i < 4; i++) { @@ -142,11 +143,11 @@ public class CombatTargetsWorldApiTests Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); - var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); - Assert.Equal(0, alpha.CurrentHp); - Assert.True(alpha.Defeated); - var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); - Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); - Assert.False(beta.Defeated); + var melee = body!.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId); + Assert.Equal(0, melee.CurrentHp); + Assert.True(melee.Defeated); + var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId); + Assert.Equal(80, ranged.CurrentHp); + Assert.False(ranged.Defeated); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs index 17f4b66..0d2dd62 100644 --- a/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Gigs; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Tests.Game.PositionState; @@ -27,7 +28,7 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act — defeat alpha through first host (four pulses) @@ -45,7 +46,7 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); for (var i = 0; i < 4; i++) { diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcRegistryTests.cs new file mode 100644 index 0000000..bdc8a94 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcRegistryTests.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +public class PrototypeNpcRegistryTests +{ + /// Prototype NPC instances in registry id order (ordinal). Keep in sync with Bruno. + public static readonly string[] InstancesInIdOrder = + [ + PrototypeNpcRegistry.PrototypeNpcEliteId, + PrototypeNpcRegistry.PrototypeNpcMeleeId, + PrototypeNpcRegistry.PrototypeNpcRangedId, + ]; + + [Fact] + public void GetInstanceIdsInOrder_ShouldReturnThreeIdsInOrdinalOrder() + { + // Arrange + // Act + var ids = PrototypeNpcRegistry.GetInstanceIdsInOrder(); + // Assert + Assert.Equal(InstancesInIdOrder, ids); + } + + [Fact] + public void TryGet_ShouldResolveEachFrozenInstanceWithBehaviorBindingAndSharedLockRadius() + { + // Arrange + // Act + var meleeFound = PrototypeNpcRegistry.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee); + var rangedFound = PrototypeNpcRegistry.TryGet(PrototypeNpcRegistry.PrototypeNpcRangedId, out var ranged); + var eliteFound = PrototypeNpcRegistry.TryGet(PrototypeNpcRegistry.PrototypeNpcEliteId, out var elite); + // Assert + Assert.True(meleeFound); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, melee.BehaviorDefId); + Assert.Equal(-3, melee.X); + Assert.Equal(0.5, melee.Y); + Assert.Equal(-3, melee.Z); + Assert.Equal(PrototypeNpcRegistry.SharedLockRadius, melee.LockRadius); + + Assert.True(rangedFound); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeRangedControl, ranged.BehaviorDefId); + Assert.Equal(3, ranged.X); + Assert.Equal(0, ranged.Y); + Assert.Equal(3, ranged.Z); + + Assert.True(eliteFound); + Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, elite.BehaviorDefId); + Assert.Equal(0, elite.X); + Assert.Equal(0.5, elite.Y); + Assert.Equal(0, elite.Z); + } + + [Fact] + public void TryGet_ShouldReturnFalse_ForUnknownId() + { + // Arrange + // Act + var found = PrototypeNpcRegistry.TryGet("prototype_target_alpha", out var entry); + // Assert + Assert.False(found); + Assert.Equal(default, entry); + } + + [Fact] + public async Task HostBehaviorRegistry_ShouldResolveEachInstanceBehaviorDef() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + var registry = factory.Services.GetRequiredService(); + // Act + foreach (var id in InstancesInIdOrder) + { + Assert.True(PrototypeNpcRegistry.TryGet(id, out var entry)); + var resolved = registry.TryGetDefinition(entry.BehaviorDefId, out var definition); + // Assert + Assert.True(resolved); + Assert.Equal(entry.BehaviorDefId, definition!.Id); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs new file mode 100644 index 0000000..98caf87 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcTestFixtures.cs @@ -0,0 +1,51 @@ +using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Npc; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +/// Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91). +public static class PrototypeNpcTestFixtures +{ + public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry() + { + var byId = new Dictionary(StringComparer.Ordinal) + { + [PrototypeNpcBehaviorRegistry.PrototypeMeleePressure] = new( + PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, + "Melee Pressure", + "melee_pressure", + 100, + 8.0, + 16.0, + 1.5, + 15, + 3.0), + [PrototypeNpcBehaviorRegistry.PrototypeRangedControl] = new( + PrototypeNpcBehaviorRegistry.PrototypeRangedControl, + "Ranged Control", + "ranged_control", + 80, + 10.0, + 20.0, + 2.0, + 12, + 4.0), + [PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss] = new( + PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, + "Elite Mini-Boss", + "elite_mini_boss", + 200, + 8.0, + 18.0, + 2.5, + 25, + 5.0), + }; + + var catalog = new NpcBehaviorDefinitionCatalog("/tmp/npc-behaviors", byId, catalogJsonFileCount: 1); + return new NpcBehaviorDefinitionRegistry(catalog); + } + + public static InMemoryCombatEntityHealthStore CreateHealthStore() => + new(CreateBehaviorRegistry()); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs index 0a8f9e1..bb2d87a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; using Xunit; @@ -37,7 +38,7 @@ public class PlayerTargetStateReaderTests // Alpha anchor XZ (-3, -3), lockRadius 6 -> horizontal distance 6 from (3, -3) is on boundary (inclusive). var snap = new PositionSnapshot(3, 0, -3, 0); // Act - var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap); + var validity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap); // Assert Assert.Equal(TargetValidity.Ok, validity); } @@ -50,23 +51,24 @@ public class PlayerTargetStateReaderTests const double eps = 1e-6; var snap = new PositionSnapshot(3 + eps, 0, -3, 0); // Act - var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap); + var validity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap); // Assert Assert.Equal(TargetValidity.OutOfRange, validity); } [Fact] - public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets() + public void ComputeValidity_AtOrigin_ShouldReturnOk_ForMeleeRangedAndElite() { // Arrange - // NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can - // flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m). + // Melee (-3,-3) r=6, ranged (3,3) r=6, elite (0,0) r=6 overlap at origin for tab cycle. var snap = new PositionSnapshot(0, 0, 0, 0); // Act - var alphaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap); - var betaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap); + var meleeValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap); + var rangedValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcRangedId, in snap); + var eliteValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcEliteId, in snap); // Assert - Assert.Equal(TargetValidity.Ok, alphaValidity); - Assert.Equal(TargetValidity.Ok, betaValidity); + Assert.Equal(TargetValidity.Ok, meleeValidity); + Assert.Equal(TargetValidity.Ok, rangedValidity); + Assert.Equal(TargetValidity.Ok, eliteValidity); } } diff --git a/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs index 22d8668..6f05e74 100644 --- a/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Json; using System.Text; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; using Xunit; @@ -45,7 +46,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -54,7 +55,7 @@ public class TargetingApiTests Assert.NotNull(body); Assert.True(body!.SelectionApplied); Assert.Null(body.ReasonCode); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId); Assert.Equal(TargetValidity.Ok, body.TargetState.Validity); Assert.Equal(1, body.TargetState.Sequence); } @@ -100,7 +101,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId, }); // Assert @@ -128,7 +129,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId, PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 }, }); // Assert @@ -138,7 +139,7 @@ public class TargetingApiTests Assert.NotNull(body); Assert.True(body!.SelectionApplied); Assert.Null(body.ReasonCode); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetBetaId, body.TargetState.LockedTargetId); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcRangedId, body.TargetState.LockedTargetId); } [Fact] @@ -156,7 +157,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId, PositionHint = new PositionVector { X = 50.0, Y = 0.0, Z = 50.0 }, }); // Assert @@ -184,7 +185,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId, PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 }, }); @@ -213,7 +214,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); var clear = await client.PostAsync( @@ -247,7 +248,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); var move = new MoveCommandRequest @@ -265,7 +266,7 @@ public class TargetingApiTests Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, get.StatusCode); Assert.NotNull(body); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body!.LockedTargetId); Assert.Equal(TargetValidity.OutOfRange, body.Validity); } @@ -297,7 +298,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -318,7 +319,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = 999, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -360,7 +361,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = " PROTOTYPE_TARGET_ALPHA ", + TargetId = " PROTOTYPE_NPC_MELEE ", }); // Assert @@ -368,7 +369,7 @@ public class TargetingApiTests var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); Assert.True(body!.SelectionApplied); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId); } [Fact] @@ -382,7 +383,7 @@ public class TargetingApiTests var req = new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }; // Act var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req); @@ -407,7 +408,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); var move = new MoveCommandRequest @@ -423,7 +424,7 @@ public class TargetingApiTests new TargetSelectRequest { SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, - TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, + TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId, }); // Assert @@ -432,7 +433,7 @@ public class TargetingApiTests Assert.NotNull(body); Assert.False(body!.SelectionApplied); Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); - Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); + Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId); Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity); } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index 0a1638c..cb18bda 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -2,6 +2,7 @@ using NeonSprawl.Server.Diagnostics; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.World; @@ -149,7 +150,7 @@ public static class AbilityCastApi } var lookupKey = body.TargetId.Trim().ToLowerInvariant(); - if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) + if (!PrototypeNpcRegistry.TryGet(lookupKey, out var entry)) { // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit). return JsonAbilityCast( diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 16b92e4..4caee23 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -1,4 +1,4 @@ -using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Combat; @@ -16,7 +16,7 @@ public static class CombatTargetFixtureApi return Results.BadRequest(); } - foreach (var id in PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()) + foreach (var id in PrototypeNpcRegistry.GetInstanceIdsInOrder()) { if (!healthStore.TryResetToFull(id, out _)) { diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs index ef1e40c..d17302d 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs @@ -1,4 +1,4 @@ -using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Combat; @@ -11,7 +11,7 @@ public static class CombatTargetsWorldApi "/game/world/combat-targets", (ICombatEntityHealthStore healthStore) => { - var ids = PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder(); + var ids = PrototypeNpcRegistry.GetInstanceIdsInOrder(); var targets = new List(ids.Count); foreach (var id in ids) { diff --git a/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs index e53b324..4a5a770 100644 --- a/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs +++ b/server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs @@ -1,8 +1,8 @@ namespace NeonSprawl.Server.Game.Combat; /// -/// Server-owned HP store for prototype combat dummies keyed by -/// target id (NEO-80). +/// Server-owned HP store for prototype NPC combat instances keyed by +/// instance id (NEO-80, NEO-91). /// /// /// NEO-81+: combat resolution should depend on this interface rather than reaching into store internals. @@ -11,8 +11,8 @@ namespace NeonSprawl.Server.Game.Combat; public interface ICombatEntityHealthStore { /// - /// Reads the current snapshot for a known prototype target. Lazy-initializes HP to - /// on first access. + /// Reads the current snapshot for a known prototype NPC instance. Lazy-initializes HP to + /// the bound behavior def maxHp on first access. /// bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot); diff --git a/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs b/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs index 7e102b4..4a3402f 100644 --- a/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs +++ b/server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs @@ -1,10 +1,10 @@ using System.Collections.Concurrent; -using NeonSprawl.Server.Game.Targeting; +using NeonSprawl.Server.Game.Npc; namespace NeonSprawl.Server.Game.Combat; -/// Thread-safe in-memory HP for prototype combat dummies; empty at startup — lazy rows only (NEO-80). -public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore +/// Thread-safe in-memory HP for prototype NPC combat instances; empty at startup — lazy rows only (NEO-80, NEO-91). +public sealed class InMemoryCombatEntityHealthStore(INpcBehaviorDefinitionRegistry behaviorRegistry) : ICombatEntityHealthStore { private readonly ConcurrentDictionary currentHpById = new(StringComparer.Ordinal); @@ -14,7 +14,7 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore public bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot) { var key = NormalizeTargetId(targetId); - if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) + if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp)) { snapshot = default; return false; @@ -22,8 +22,8 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore lock (idLocks.GetOrAdd(key, _ => new object())) { - EnsureRowInitialized(key); - snapshot = CreateSnapshot(key, currentHpById[key]); + EnsureRowInitialized(key, maxHp); + snapshot = CreateSnapshot(key, maxHp, currentHpById[key]); return true; } } @@ -38,18 +38,18 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore } var key = NormalizeTargetId(targetId); - if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) + if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp)) { return false; } lock (idLocks.GetOrAdd(key, _ => new object())) { - EnsureRowInitialized(key); + EnsureRowInitialized(key, maxHp); var current = currentHpById[key]; var next = Math.Max(0, current - damage); currentHpById[key] = next; - snapshot = CreateSnapshot(key, next); + snapshot = CreateSnapshot(key, maxHp, next); return true; } } @@ -58,7 +58,7 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore public bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot) { var key = NormalizeTargetId(targetId); - if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) + if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp)) { snapshot = default; return false; @@ -66,24 +66,41 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore lock (idLocks.GetOrAdd(key, _ => new object())) { - currentHpById[key] = PrototypeCombatConstants.MaxPrototypeTargetHp; - snapshot = CreateSnapshot(key, PrototypeCombatConstants.MaxPrototypeTargetHp); + currentHpById[key] = maxHp; + snapshot = CreateSnapshot(key, maxHp, maxHp); return true; } } - private void EnsureRowInitialized(string normalizedId) + private bool TryResolveMaxHp(string normalizedId, out int maxHp) + { + maxHp = 0; + if (!PrototypeNpcRegistry.TryGet(normalizedId, out var entry)) + { + return false; + } + + if (!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var definition)) + { + return false; + } + + maxHp = definition.MaxHp; + return true; + } + + private void EnsureRowInitialized(string normalizedId, int maxHp) { if (!currentHpById.ContainsKey(normalizedId)) { - currentHpById[normalizedId] = PrototypeCombatConstants.MaxPrototypeTargetHp; + currentHpById[normalizedId] = maxHp; } } - private static CombatEntityHealthSnapshot CreateSnapshot(string targetId, int currentHp) => + private static CombatEntityHealthSnapshot CreateSnapshot(string targetId, int maxHp, int currentHp) => new( targetId, - PrototypeCombatConstants.MaxPrototypeTargetHp, + maxHp, currentHp, currentHp <= 0); diff --git a/server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs deleted file mode 100644 index 54111ad..0000000 --- a/server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace NeonSprawl.Server.Game.Combat; - -/// Frozen prototype combat tuning for E5.M1 Slice 1 (NEO-80). -public static class PrototypeCombatConstants -{ - /// - /// Shared max HP for combat dummies. - /// Four prototype_pulse casts at 25 damage each defeat one dummy (100 HP). - /// - public const int MaxPrototypeTargetHp = 100; -} diff --git a/server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs b/server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs new file mode 100644 index 0000000..c2c38fa --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs @@ -0,0 +1,71 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Prototype NPC combat instance ids → behavior binding + world anchor + horizontal lock radius (NEO-91). +/// Keys are lowercase. Replaces PrototypeTargetRegistry alpha/beta dummies. +/// +public static class PrototypeNpcRegistry +{ + /// + /// All prototype NPC instances share a single LockRadius. Changing this requires updating + /// client/scripts/prototype_target_constants.gd in NEO-97 so markers/HUD match server range checks. + /// + public const double SharedLockRadius = 6.0; + + public const string PrototypeNpcMeleeId = "prototype_npc_melee"; + + public const string PrototypeNpcRangedId = "prototype_npc_ranged"; + + public const string PrototypeNpcEliteId = "prototype_npc_elite"; + + /// Former alpha anchor; overlaps elite at origin for tab cycle (NEO-24 pattern). + public static readonly PrototypeNpcInstanceEntry PrototypeNpcMelee = new( + PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, + -3, + 0.5, + -3, + SharedLockRadius); + + /// Former beta anchor; mirrors melee across the origin. + public static readonly PrototypeNpcInstanceEntry PrototypeNpcRanged = new( + PrototypeNpcBehaviorRegistry.PrototypeRangedControl, + 3, + 0, + 3, + SharedLockRadius); + + /// Center anchor; overlaps melee/ranged rings at origin (~4.24 m from each). + public static readonly PrototypeNpcInstanceEntry PrototypeNpcElite = new( + PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss, + 0, + 0.5, + 0, + SharedLockRadius); + + private static readonly Dictionary ById = new(StringComparer.Ordinal) + { + [PrototypeNpcMeleeId] = PrototypeNpcMelee, + [PrototypeNpcRangedId] = PrototypeNpcRanged, + [PrototypeNpcEliteId] = PrototypeNpcElite, + }; + + public static bool TryGet(string instanceIdLowercase, out PrototypeNpcInstanceEntry entry) => + ById.TryGetValue(instanceIdLowercase, out entry); + + /// All prototype NPC instance ids in ascending ordinal order (NEO-83 / NEO-91). + public static IReadOnlyList GetInstanceIdsInOrder() + { + var ids = ById.Keys.ToList(); + ids.Sort(StringComparer.Ordinal); + return ids; + } +} + +/// Frozen behavior catalog id bound to this instance. +/// Horizontal reach on X/Z; inclusive boundary (same as NEO-9). +public readonly record struct PrototypeNpcInstanceEntry( + string BehaviorDefId, + double X, + double Y, + double Z, + double LockRadius); diff --git a/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs b/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs index 1d3a800..51e7bc1 100644 --- a/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs +++ b/server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.World; @@ -13,7 +14,7 @@ public static class PlayerTargetStateReader return TargetValidity.None; } - if (!PrototypeTargetRegistry.TryGet(lockIdLowercase, out var entry)) + if (!PrototypeNpcRegistry.TryGet(lockIdLowercase, out var entry)) { return TargetValidity.InvalidTarget; } diff --git a/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs b/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs deleted file mode 100644 index 0442d60..0000000 --- a/server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace NeonSprawl.Server.Game.Targeting; - -/// Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase. -public static class PrototypeTargetRegistry -{ - /// - /// All prototype combat targets share a single LockRadius. Scoped to prototype - /// stubs — per-target radii will come back with real combat design (E5.M1). Changing - /// this requires updating client/scripts/prototype_target_constants.gd in the - /// same commit so markers/HUD match server range checks. - /// - public const double SharedLockRadius = 6.0; - - /// On the -x/-z side of spawn; overlaps 's ring at origin so Tab can flip without moving. - public const string PrototypeTargetAlphaId = "prototype_target_alpha"; - - /// On the +x/+z side; mirrors alpha across the origin for symmetric overlap (NEO-24 Tab cycle). - public const string PrototypeTargetBetaId = "prototype_target_beta"; - - /// Alpha anchor; is XZ-only. Distance to beta ~8.49 m ⇒ ~3.5 m overlap at origin. - public static readonly PrototypeTargetEntry PrototypeTargetAlpha = new(-3, 0.5, -3, SharedLockRadius); - - public static readonly PrototypeTargetEntry PrototypeTargetBeta = new(3, 0, 3, SharedLockRadius); - - private static readonly Dictionary ById = new(StringComparer.Ordinal) - { - [PrototypeTargetAlphaId] = PrototypeTargetAlpha, - [PrototypeTargetBetaId] = PrototypeTargetBeta, - }; - - public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) => - ById.TryGetValue(targetIdLowercase, out entry); - - /// All prototype combat target ids in ascending ordinal order (NEO-83). - public static IReadOnlyList GetPrototypeTargetIdsInOrder() - { - var ids = ById.Keys.ToList(); - ids.Sort(StringComparer.Ordinal); - return ids; - } -} - -/// Horizontal reach on X/Z; inclusive boundary (same as NEO-9). -public readonly record struct PrototypeTargetEntry(double X, double Y, double Z, double LockRadius); diff --git a/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs b/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs index 49020df..36b33e5 100644 --- a/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs +++ b/server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs @@ -1,3 +1,4 @@ +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.World; @@ -64,7 +65,7 @@ public static class TargetingApi } var lookupKey = trimmed.ToLowerInvariant(); - if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry)) + if (!PrototypeNpcRegistry.TryGet(lookupKey, out var entry)) { var (lockUnknown, seqUnknown) = locks.GetLockState(id); var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap); diff --git a/server/README.md b/server/README.md index 96e8eaa..d51bf23 100644 --- a/server/README.md +++ b/server/README.md @@ -122,27 +122,29 @@ curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions" curl -sS -i "http://localhost:5253/game/world/ability-definitions" ``` -## Combat entity health (NEO-80) +## Combat entity health (NEO-80, NEO-91) -Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeTargetRegistry`** ids (**`prototype_target_alpha`**, **`prototype_target_beta`**) only. Max HP is frozen at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** — four **`prototype_pulse`** casts (25 damage each) defeat one dummy. +Server-owned HP for prototype NPC combat instances lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeNpcRegistry`** instance ids (**`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`**) only. Max HP comes from each instance's bound **`NpcBehaviorDef.maxHp`** via **`INpcBehaviorDefinitionRegistry`** (melee **100**, ranged **80**, elite **200** — four **`prototype_pulse`** casts defeat melee). | Policy | Behavior | |--------|----------| -| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id. | +| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id; **`currentHp`** starts at catalog **`maxHp`**. | | **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is **NEO-81** (`CombatOperations`). | -| **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives a dummy for tests/fixtures — not exposed over HTTP in Slice 1. | +| **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives an instance for tests/fixtures — not exposed over HTTP in Slice 1. | | **HTTP read** | **`GET /game/world/combat-targets`** (NEO-83) — authoritative HP snapshot for client HUD; cast wiring is **NEO-82**. | -Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md); HTTP read: [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83). +Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md); instance registry migration: [NEO-91 implementation plan](../../docs/plans/NEO-91-implementation-plan.md); HTTP read: [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83). -## Combat targets snapshot (NEO-83) +**Breaking change (NEO-91):** **`prototype_target_alpha`** / **`prototype_target_beta`** are removed. Godot markers and tab cycle must migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) (`client/scripts/prototype_target_constants.gd`). -**`GET /game/world/combat-targets`** returns a versioned JSON body (`schemaVersion` **1**, **`targets`**) backed by **`ICombatEntityHealthStore`** — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes **`targetId`**, **`maxHp`**, **`currentHp`**, and **`defeated`**. Both **`PrototypeTargetRegistry`** dummies are always returned in ascending **`targetId`** order; lazy-init on first read matches NEO-80. Plan: [NEO-83 implementation plan](../../docs/plans/NEO-83-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-targets/`. +## Combat targets snapshot (NEO-83, NEO-91) + +**`GET /game/world/combat-targets`** returns a versioned JSON body (`schemaVersion` **1**, **`targets`**) backed by **`ICombatEntityHealthStore`** — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes **`targetId`**, **`maxHp`**, **`currentHp`**, and **`defeated`**. All three **`PrototypeNpcRegistry`** instances are always returned in ascending **`targetId`** order; lazy-init on first read matches NEO-80/NEO-91. Plan: [NEO-83 implementation plan](../../docs/plans/NEO-83-implementation-plan.md); [NEO-91 implementation plan](../../docs/plans/NEO-91-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-targets/`. | Field | Meaning | |-------|---------| -| **`targetId`** | Lowercase prototype registry id (`prototype_target_alpha`, `prototype_target_beta`). | -| **`maxHp`** | Frozen prototype max (**100**). | +| **`targetId`** | Lowercase NPC instance id (`prototype_npc_elite`, `prototype_npc_melee`, `prototype_npc_ranged`). | +| **`maxHp`** | Catalog max from bound behavior def (200 / 100 / 80). | | **`currentHp`** | Authoritative HP after cast-applied damage; floored at zero. | | **`defeated`** | **`true`** when **`currentHp`** is **0**. | @@ -150,7 +152,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m curl -sS -i "http://localhost:5253/game/world/combat-targets" ``` -**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeTargetRegistry`** dummies to full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. +**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`. ## Combat engine (NEO-81) @@ -159,7 +161,7 @@ curl -sS -i "http://localhost:5253/game/world/combat-targets" | Reason code | When | |-------------|------| | **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). | -| **`unknown_target`** | **`targetId`** not in **`PrototypeTargetRegistry`** (health store gate). | +| **`unknown_target`** | **`targetId`** not in **`PrototypeNpcRegistry`** (health store gate). | | **`target_defeated`** | Target HP is already **0** before resolve; no damage applied (includes zero-damage utility). | **`CombatResult`** fields: **`success`**, **`reasonCode`** (`null` on success), **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**. On **`target_defeated`** deny, **`targetRemainingHp`** is **0** and **`targetDefeated`** on the envelope is **`false`** — use **`reasonCode`** for deny semantics. @@ -542,7 +544,7 @@ Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/targ **Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)). -Stub registry: `PrototypeTargetRegistry` in `NeonSprawl.Server/Game/Targeting/` — ids **`prototype_target_alpha`**, **`prototype_target_beta`** (ascending id order for future tab cycle). +Stub registry: **`PrototypeNpcRegistry`** in `NeonSprawl.Server/Game/Npc/` — ids **`prototype_npc_elite`**, **`prototype_npc_melee`**, **`prototype_npc_ranged`** (ascending id order for tab cycle; client mirror in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)). **`GET /game/players/{id}/target`** @@ -565,7 +567,7 @@ curl -s http://localhost:5253/game/players/dev-local-1/target ```bash curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \ -H "Content-Type: application/json" \ - -d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}' + -d '{"schemaVersion":1,"targetId":"prototype_npc_melee"}' ``` **HTTP** @@ -611,7 +613,7 @@ Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_da Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**. -- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**. +- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeNpcRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**. **Accept payload (`combatResolution`, present only when `accepted: true`):**