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 <cursoragent@cursor.com>
pull/128/head
Don 2026-05-26 23:42:31 -04:00
parent dadc35afa8
commit fa9acc85ac
36 changed files with 540 additions and 317 deletions

View File

@ -27,7 +27,7 @@ script:pre-request {
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`, `${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_target_beta" }, { schemaVersion: 1, targetId: "prototype_npc_ranged" },
jsonHeaders, jsonHeaders,
); );
} }
@ -43,7 +43,7 @@ body:json {
"schemaVersion": 1, "schemaVersion": 1,
"slotIndex": 0, "slotIndex": 0,
"abilityId": "prototype_pulse", "abilityId": "prototype_pulse",
"targetId": "prototype_target_beta" "targetId": "prototype_npc_ranged"
} }
} }
@ -60,7 +60,7 @@ tests {
expect(body.accepted).to.equal(true); expect(body.accepted).to.equal(true);
expect(body.combatResolution).to.be.an("object"); expect(body.combatResolution).to.be.an("object");
expect(body.combatResolution.abilityId).to.equal("prototype_pulse"); 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.damageDealt).to.equal(25);
expect(body.combatResolution.targetRemainingHp).to.equal(75); expect(body.combatResolution.targetRemainingHp).to.equal(75);
expect(body.combatResolution.targetDefeated).to.equal(false); expect(body.combatResolution.targetDefeated).to.equal(false);

View File

@ -35,7 +35,7 @@ script:pre-request {
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`, `${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_target_alpha" }, { schemaVersion: 1, targetId: "prototype_npc_melee" },
jsonHeaders, jsonHeaders,
); );
@ -43,7 +43,7 @@ script:pre-request {
schemaVersion: 1, schemaVersion: 1,
slotIndex: 1, slotIndex: 1,
abilityId: "prototype_pulse", abilityId: "prototype_pulse",
targetId: "prototype_target_alpha", targetId: "prototype_npc_melee",
}; };
const castResults = []; const castResults = [];
@ -80,7 +80,7 @@ body:json {
"schemaVersion": 1, "schemaVersion": 1,
"slotIndex": 1, "slotIndex": 1,
"abilityId": "prototype_pulse", "abilityId": "prototype_pulse",
"targetId": "prototype_target_alpha" "targetId": "prototype_npc_melee"
} }
} }

View File

@ -5,7 +5,7 @@ meta {
} }
docs { 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 1011 resource-node gather). Runs before hotbar/target/cast (seq 2022). 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 1011 resource-node gather). Runs before hotbar/target/cast (seq 2022).
} }
post { post {

View File

@ -27,7 +27,7 @@ post {
body:json { body:json {
{ {
"schemaVersion": 1, "schemaVersion": 1,
"targetId": "prototype_target_alpha" "targetId": "prototype_npc_melee"
} }
} }
@ -36,6 +36,6 @@ tests {
expect(res.getStatus()).to.equal(200); expect(res.getStatus()).to.equal(200);
const body = res.getBody(); const body = res.getBody();
expect(body.selectionApplied).to.equal(true); expect(body.selectionApplied).to.equal(true);
expect(body.targetState.lockedTargetId).to.equal("prototype_target_alpha"); expect(body.targetState.lockedTargetId).to.equal("prototype_npc_melee");
}); });
} }

View File

@ -38,7 +38,7 @@ script:pre-request {
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`, `${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_target_alpha" }, { schemaVersion: 1, targetId: "prototype_npc_melee" },
jsonHeaders, jsonHeaders,
); );
@ -46,7 +46,7 @@ script:pre-request {
schemaVersion: 1, schemaVersion: 1,
slotIndex: 1, slotIndex: 1,
abilityId: "prototype_pulse", abilityId: "prototype_pulse",
targetId: "prototype_target_alpha", targetId: "prototype_npc_melee",
}; };
const castResults = []; const castResults = [];
@ -82,7 +82,7 @@ tests {
expect(res.getStatus()).to.equal(200); expect(res.getStatus()).to.equal(200);
const body = res.getBody(); const body = res.getBody();
expect(body.schemaVersion).to.equal(1); 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 () { test("cast results stepped HP before GET", function () {
@ -95,24 +95,24 @@ tests {
expect(castResults[3].combatResolution.targetDefeated).to.equal(true); 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 body = res.getBody();
const alpha = body.targets.find( const melee = body.targets.find(
(x) => x.targetId === "prototype_target_alpha", (x) => x.targetId === "prototype_npc_melee",
); );
expect(alpha).to.be.an("object"); expect(melee).to.be.an("object");
expect(alpha.currentHp).to.equal(0); expect(melee.currentHp).to.equal(0);
expect(alpha.maxHp).to.equal(100); expect(melee.maxHp).to.equal(100);
expect(alpha.defeated).to.equal(true); 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 body = res.getBody();
const beta = body.targets.find( const ranged = body.targets.find(
(x) => x.targetId === "prototype_target_beta", (x) => x.targetId === "prototype_npc_ranged",
); );
expect(beta).to.be.an("object"); expect(ranged).to.be.an("object");
expect(beta.currentHp).to.equal(100); expect(ranged.currentHp).to.equal(80);
expect(beta.defeated).to.equal(false); expect(ranged.defeated).to.equal(false);
}); });
} }

View File

@ -34,7 +34,7 @@ script:pre-request {
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`, `${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_target_alpha" }, { schemaVersion: 1, targetId: "prototype_npc_melee" },
jsonHeaders, jsonHeaders,
); );
@ -44,7 +44,7 @@ script:pre-request {
schemaVersion: 1, schemaVersion: 1,
slotIndex: 0, slotIndex: 0,
abilityId: "prototype_pulse", abilityId: "prototype_pulse",
targetId: "prototype_target_alpha", targetId: "prototype_npc_melee",
}, },
jsonHeaders, jsonHeaders,
); );
@ -70,24 +70,25 @@ tests {
expect(castBody.combatResolution.targetDefeated).to.equal(false); 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 body = res.getBody();
const alpha = body.targets.find( const melee = body.targets.find(
(x) => x.targetId === "prototype_target_alpha", (x) => x.targetId === "prototype_npc_melee",
); );
expect(alpha).to.be.an("object"); expect(melee).to.be.an("object");
expect(alpha.currentHp).to.equal(75); expect(melee.currentHp).to.equal(75);
expect(alpha.maxHp).to.equal(100); expect(melee.maxHp).to.equal(100);
expect(alpha.defeated).to.equal(false); 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 body = res.getBody();
const beta = body.targets.find( const ranged = body.targets.find(
(x) => x.targetId === "prototype_target_beta", (x) => x.targetId === "prototype_npc_ranged",
); );
expect(beta).to.be.an("object"); expect(ranged).to.be.an("object");
expect(beta.currentHp).to.equal(100); expect(ranged.currentHp).to.equal(80);
expect(beta.defeated).to.equal(false); expect(ranged.maxHp).to.equal(80);
expect(ranged.defeated).to.equal(false);
}); });
} }

View File

@ -22,7 +22,7 @@ tests {
const body = res.getBody(); const body = res.getBody();
expect(body.schemaVersion).to.equal(1); expect(body.schemaVersion).to.equal(1);
expect(body.targets).to.be.an("array"); 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 () { test("targets are ascending by targetId (ordinal)", function () {
@ -32,21 +32,27 @@ tests {
expect(ids).to.eql(sorted); 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 body = res.getBody();
const ids = body.targets.map((x) => x.targetId); const ids = body.targets.map((x) => x.targetId);
expect(ids).to.eql([ expect(ids).to.eql([
"prototype_target_alpha", "prototype_npc_elite",
"prototype_target_beta", "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(); const body = res.getBody();
for (const row of body.targets) { const byId = Object.fromEntries(body.targets.map((x) => [x.targetId, x]));
expect(row.maxHp).to.equal(100); expect(byId.prototype_npc_elite.maxHp).to.equal(200);
expect(row.currentHp).to.equal(100); expect(byId.prototype_npc_elite.currentHp).to.equal(200);
expect(row.defeated).to.equal(false); 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);
}); });
} }

View File

@ -56,7 +56,7 @@ script:pre-request {
await axios.post( await axios.post(
`${baseUrl}/game/players/${playerId}/target/select`, `${baseUrl}/game/players/${playerId}/target/select`,
{ schemaVersion: 1, targetId: "prototype_target_alpha" }, { schemaVersion: 1, targetId: "prototype_npc_melee" },
jsonHeaders, jsonHeaders,
); );
@ -64,7 +64,7 @@ script:pre-request {
schemaVersion: 1, schemaVersion: 1,
slotIndex: 2, slotIndex: 2,
abilityId: "prototype_pulse", abilityId: "prototype_pulse",
targetId: "prototype_target_alpha", targetId: "prototype_npc_melee",
}; };
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {

View File

@ -1,5 +1,5 @@
meta { meta {
name: POST target select - prototype_target_alpha name: POST target select - prototype_npc_melee
type: http type: http
seq: 11 seq: 11
} }
@ -17,6 +17,6 @@ headers {
body:json { body:json {
{ {
"schemaVersion": 1, "schemaVersion": 1,
"targetId": "prototype_target_alpha" "targetId": "prototype_npc_melee"
} }
} }

View File

@ -1,5 +1,5 @@
meta { meta {
name: POST target select - prototype_target_beta (with positionHint) name: POST target select - prototype_npc_ranged (with positionHint)
type: http type: http
seq: 13 seq: 13
} }
@ -17,7 +17,7 @@ headers {
body:json { body:json {
{ {
"schemaVersion": 1, "schemaVersion": 1,
"targetId": "prototype_target_beta", "targetId": "prototype_npc_ranged",
"positionHint": { "x": 3.0, "y": 0.5, "z": 5.0 } "positionHint": { "x": 3.0, "y": 0.5, "z": 5.0 }
} }
} }

View File

@ -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 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/`. **NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
## Risks and telemetry ## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -190,9 +190,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria** **Acceptance criteria**
- [ ] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed). - [x] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed).
- [ ] Each instance references a valid frozen `behaviorDefId`. - [x] Each instance references a valid frozen `behaviorDefId`.
- [ ] Bruno defeat spine passes for at least one archetype. - [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). **Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — archetype markers + telegraph HUD (after runtime snapshot lands).

View File

@ -42,9 +42,9 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Cast + combat-targets GET operate on **three NPC ids only** (alpha/beta removed). - [x] Cast + combat-targets GET operate on **three NPC ids only** (alpha/beta removed).
- [ ] Each instance references a valid frozen **`behaviorDefId`** resolvable via **`INpcBehaviorDefinitionRegistry`**. - [x] Each instance references a valid frozen **`behaviorDefId`** resolvable via **`INpcBehaviorDefinitionRegistry`**.
- [ ] Bruno defeat spine passes for **`prototype_npc_melee`** (4× pulse → defeated). - [x] Bruno defeat spine passes for **`prototype_npc_melee`** (4× pulse → defeated).
## Technical approach ## 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** | | **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** | | **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). - **`PrototypeNpcRegistry`** landed in `Game/Npc/`; **`PrototypeTargetRegistry`** and **`PrototypeCombatConstants`** removed.
- **Anchors:** melee `(-3, 0.5, -3)`, ranged `(3, 0, 3)`, elite `(0, 0.5, 0)`; lock radius **6.0** (user confirmed). - **`InMemoryCombatEntityHealthStore`** injects **`INpcBehaviorDefinitionRegistry`** for per-instance catalog max HP.
- **Behavior binding:** 1:1 melee/ranged/elite → frozen behavior ids (adopted). - Server tests + Bruno migrated to three NPC ids; melee 4× pulse defeat spine is AC verification.
- **Bruno defeat spine:** **`prototype_npc_melee`** (user confirmed). - **Client not updated** — [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) owns `prototype_target_constants.gd` migration.

View File

@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using Xunit; using Xunit;
@ -39,7 +40,7 @@ public sealed class AbilityCastApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>(); var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
@ -65,7 +66,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z) private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z)
@ -97,7 +98,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -108,7 +109,7 @@ public sealed class AbilityCastApiTests
Assert.Null(body.ReasonCode); Assert.Null(body.ReasonCode);
Assert.NotNull(body.CombatResolution); Assert.NotNull(body.CombatResolution);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId); 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(25, body.CombatResolution.DamageDealt);
Assert.Equal(75, body.CombatResolution.TargetRemainingHp); Assert.Equal(75, body.CombatResolution.TargetRemainingHp);
Assert.False(body.CombatResolution.TargetDefeated); Assert.False(body.CombatResolution.TargetDefeated);
@ -131,7 +132,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = "prototype_target_alpha", TargetId = " Prototype_Npc_Melee ",
}); });
// Assert // Assert
@ -214,7 +215,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
}); });
// Assert // Assert
@ -241,7 +242,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -270,7 +271,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -485,7 +486,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act // Act
@ -518,7 +519,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act // Act
@ -552,7 +553,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypeGuard, AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act // Act
@ -691,7 +692,7 @@ public sealed class AbilityCastApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypeGuard, AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act // Act

View File

@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
@ -28,7 +29,7 @@ public sealed class CooldownSnapshotApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
@ -81,7 +82,7 @@ public sealed class CooldownSnapshotApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act // Act

View File

@ -1,7 +1,8 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat; namespace NeonSprawl.Server.Tests.Game.Combat;
@ -9,17 +10,17 @@ namespace NeonSprawl.Server.Tests.Game.Combat;
public sealed class CombatEntityHealthStoreTests public sealed class CombatEntityHealthStoreTests
{ {
[Fact] [Fact]
public void TryGet_OnFirstAccess_ShouldLazyInitAlphaToFullHp() public void TryGet_OnFirstAccess_ShouldLazyInitMeleeToCatalogMaxHp()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert // Assert
Assert.True(found); Assert.True(found);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId); Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.TargetId);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.MaxHp); Assert.Equal(100, snapshot.MaxHp);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); Assert.Equal(100, snapshot.CurrentHp);
Assert.False(snapshot.Defeated); Assert.False(snapshot.Defeated);
} }
@ -27,9 +28,9 @@ public sealed class CombatEntityHealthStoreTests
public void TryApplyDamage_ShouldReduceHp_WhenDamageIsApplied() public void TryApplyDamage_ShouldReduceHp_WhenDamageIsApplied()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot); var applied = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var snapshot);
// Assert // Assert
Assert.True(applied); Assert.True(applied);
Assert.Equal(75, snapshot.CurrentHp); Assert.Equal(75, snapshot.CurrentHp);
@ -40,12 +41,12 @@ public sealed class CombatEntityHealthStoreTests
public void TryApplyDamage_RepeatedUntilZero_ShouldMarkDefeated() public void TryApplyDamage_RepeatedUntilZero_ShouldMarkDefeated()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var first = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); var first = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _);
var second = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); var second = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _);
var third = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _); var third = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _);
var fourth = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var defeated); var fourth = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var defeated);
// Assert // Assert
Assert.True(first); Assert.True(first);
Assert.True(second); Assert.True(second);
@ -59,10 +60,10 @@ public sealed class CombatEntityHealthStoreTests
public void TryApplyDamage_OnDefeatedTarget_ShouldKeepHpAtZeroAndRemainDefeated() public void TryApplyDamage_OnDefeatedTarget_ShouldKeepHpAtZeroAndRemainDefeated()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
// Act // Act
var overkill = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot); var overkill = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var snapshot);
// Assert // Assert
Assert.True(overkill); Assert.True(overkill);
Assert.Equal(0, snapshot.CurrentHp); Assert.Equal(0, snapshot.CurrentHp);
@ -73,36 +74,51 @@ public sealed class CombatEntityHealthStoreTests
public void TryResetToFull_AfterDefeat_ShouldRestoreFullHpAndClearDefeated() public void TryResetToFull_AfterDefeat_ShouldRestoreFullHpAndClearDefeated()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
// Act // Act
var reset = store.TryResetToFull(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); var reset = store.TryResetToFull(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert // Assert
Assert.True(reset); Assert.True(reset);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); Assert.Equal(100, snapshot.CurrentHp);
Assert.False(snapshot.Defeated); Assert.False(snapshot.Defeated);
} }
[Fact] [Fact]
public void TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha() public void TryGet_OnRanged_ShouldLazyInitIndependentlyFromMelee()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out var alphaAfterDamage); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 40, out var meleeAfterDamage);
// Act // Act
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetBetaId, out var beta); var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcRangedId, out var ranged);
// Assert // Assert
Assert.True(found); Assert.True(found);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); Assert.Equal(80, ranged.MaxHp);
Assert.False(beta.Defeated); Assert.Equal(80, ranged.CurrentHp);
Assert.Equal(60, alphaAfterDamage.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] [Fact]
public void TryGet_ShouldReturnFalse_WhenTargetIdIsUnknown() public void TryGet_ShouldReturnFalse_WhenTargetIdIsUnknown()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var found = store.TryGet("not_a_prototype_target", out var snapshot); var found = store.TryGet("not_a_prototype_target", out var snapshot);
// Assert // Assert
@ -114,7 +130,7 @@ public sealed class CombatEntityHealthStoreTests
public void TryGet_ShouldReturnFalse_WhenTargetIdIsEmpty() public void TryGet_ShouldReturnFalse_WhenTargetIdIsEmpty()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var found = store.TryGet(" ", out var snapshot); var found = store.TryGet(" ", out var snapshot);
// Assert // Assert
@ -126,9 +142,9 @@ public sealed class CombatEntityHealthStoreTests
public void TryApplyDamage_ShouldReturnFalse_WhenDamageIsNegative() public void TryApplyDamage_ShouldReturnFalse_WhenDamageIsNegative()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, -1, out var snapshot); var applied = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, -1, out var snapshot);
// Assert // Assert
Assert.False(applied); Assert.False(applied);
Assert.Equal(default, snapshot); Assert.Equal(default, snapshot);
@ -138,7 +154,7 @@ public sealed class CombatEntityHealthStoreTests
public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsUnknown() public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsUnknown()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var applied = store.TryApplyDamage("not_a_prototype_target", 25, out var snapshot); var applied = store.TryApplyDamage("not_a_prototype_target", 25, out var snapshot);
// Assert // Assert
@ -150,7 +166,7 @@ public sealed class CombatEntityHealthStoreTests
public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsEmpty() public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsEmpty()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var applied = store.TryApplyDamage(" ", 25, out var snapshot); var applied = store.TryApplyDamage(" ", 25, out var snapshot);
// Assert // Assert
@ -162,20 +178,32 @@ public sealed class CombatEntityHealthStoreTests
public void TryGet_ShouldNormalizeCaseVariantTargetIds() public void TryGet_ShouldNormalizeCaseVariantTargetIds()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var found = store.TryGet(" Prototype_Target_Alpha ", out var snapshot); var found = store.TryGet(" Prototype_Npc_Melee ", out var snapshot);
// Assert // Assert
Assert.True(found); Assert.True(found);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId); Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.TargetId);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); 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] [Fact]
public void TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown() public void TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown()
{ {
// Arrange // Arrange
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var reset = store.TryResetToFull("not_a_prototype_target", out var snapshot); var reset = store.TryResetToFull("not_a_prototype_target", out var snapshot);
// Assert // Assert
@ -192,10 +220,10 @@ public sealed class CombatEntityHealthStoreTests
_ = await client.GetAsync("/health"); _ = await client.GetAsync("/health");
// Act // Act
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>(); var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert // Assert
Assert.True(found); Assert.True(found);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); Assert.Equal(100, snapshot.CurrentHp);
Assert.False(snapshot.Defeated); Assert.False(snapshot.Defeated);
} }
} }

View File

@ -1,8 +1,9 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests; using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat; namespace NeonSprawl.Server.Tests.Game.Combat;
@ -47,11 +48,11 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
// Assert // Assert
@ -67,12 +68,12 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
for (var i = 0; i < 3; i++) for (var i = 0; i < 3; i++)
{ {
_ = CombatOperations.TryResolve( _ = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
} }
@ -80,7 +81,7 @@ public sealed class CombatOperationsTests
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
@ -95,12 +96,12 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
for (var i = 0; i < 4; i++) for (var i = 0; i < 4; i++)
{ {
_ = CombatOperations.TryResolve( _ = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
} }
@ -108,10 +109,10 @@ public sealed class CombatOperationsTests
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert // Assert
Assert.False(result.Success); Assert.False(result.Success);
@ -128,22 +129,22 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeGuard, PrototypeAbilityRegistry.PrototypeGuard,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot); store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert // Assert
Assert.True(result.Success); Assert.True(result.Success);
Assert.Null(result.ReasonCode); Assert.Null(result.ReasonCode);
Assert.Equal(0, result.DamageDealt); Assert.Equal(0, result.DamageDealt);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp); Assert.Equal(100, result.TargetRemainingHp);
Assert.False(result.TargetDefeated); Assert.False(result.TargetDefeated);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp); Assert.Equal(100, snapshot.CurrentHp);
} }
[Fact] [Fact]
@ -151,12 +152,12 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeGuard, PrototypeAbilityRegistry.PrototypeGuard,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
// Assert // Assert
@ -172,18 +173,18 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeDash, PrototypeAbilityRegistry.PrototypeDash,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
// Assert // Assert
Assert.True(result.Success); Assert.True(result.Success);
Assert.Null(result.ReasonCode); Assert.Null(result.ReasonCode);
Assert.Equal(0, result.DamageDealt); Assert.Equal(0, result.DamageDealt);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp); Assert.Equal(100, result.TargetRemainingHp);
Assert.False(result.TargetDefeated); Assert.False(result.TargetDefeated);
} }
@ -192,11 +193,11 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
"not_a_real_ability", "not_a_real_ability",
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
// Assert // Assert
@ -211,7 +212,7 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
@ -230,11 +231,11 @@ public sealed class CombatOperationsTests
{ {
// Arrange // Arrange
var registry = CreatePrototypeRegistry(); var registry = CreatePrototypeRegistry();
var store = new InMemoryCombatEntityHealthStore(); var store = PrototypeNpcTestFixtures.CreateHealthStore();
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypeBurst, PrototypeAbilityRegistry.PrototypeBurst,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
// Assert // Assert
@ -256,7 +257,7 @@ public sealed class CombatOperationsTests
// Act // Act
var result = CombatOperations.TryResolve( var result = CombatOperations.TryResolve(
PrototypeAbilityRegistry.PrototypePulse, PrototypeAbilityRegistry.PrototypePulse,
PrototypeTargetRegistry.PrototypeTargetAlphaId, PrototypeNpcRegistry.PrototypeNpcMeleeId,
registry, registry,
store); store);
// Assert // Assert

View File

@ -3,7 +3,8 @@ using System.Net.Http.Json;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Tests;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat; namespace NeonSprawl.Server.Tests.Game.Combat;
@ -19,7 +20,7 @@ public sealed class CombatTargetFixtureApiTests
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>(); var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
// Act // Act
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
@ -34,9 +35,9 @@ public sealed class CombatTargetFixtureApiTests
var body = await response.Content.ReadFromJsonAsync<CombatTargetFixtureResponse>(); var body = await response.Content.ReadFromJsonAsync<CombatTargetFixtureResponse>();
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.Applied); Assert.True(body!.Applied);
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha); store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp); Assert.Equal(100, melee.CurrentHp);
Assert.False(alpha.Defeated); Assert.False(melee.Defeated);
} }
[Fact] [Fact]

View File

@ -3,20 +3,16 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Npc;
using Xunit; using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat; namespace NeonSprawl.Server.Tests.Game.Combat;
public class CombatTargetsWorldApiTests public class CombatTargetsWorldApiTests
{ {
/// <summary>Prototype combat targets in registry id order (ordinal). Keep in sync with Bruno.</summary>
public static readonly string[] PrototypeTargetsInIdOrder =
[
PrototypeTargetRegistry.PrototypeTargetAlphaId,
PrototypeTargetRegistry.PrototypeTargetBetaId,
];
private static async Task BindSlot0PulseAsync(HttpClient client) private static async Task BindSlot0PulseAsync(HttpClient client)
{ {
var post = await client.PostAsJsonAsync( var post = await client.PostAsJsonAsync(
@ -29,14 +25,14 @@ public class CombatTargetsWorldApiTests
post.EnsureSuccessStatusCode(); post.EnsureSuccessStatusCode();
} }
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client) private static async Task LockPrototypeNpcMeleeAsync(HttpClient client)
{ {
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select", "/game/players/dev-local-1/target/select",
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>(); var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
@ -50,11 +46,11 @@ public class CombatTargetsWorldApiTests
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
[Fact] [Fact]
public async Task GetCombatTargets_ShouldReturnSchemaV1_WithBothTargetsAtFullHp() public async Task GetCombatTargets_ShouldReturnSchemaV1_WithThreeTargetsAtFullHp()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
@ -67,29 +63,34 @@ public class CombatTargetsWorldApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal(CombatTargetsListResponse.CurrentSchemaVersion, body!.SchemaVersion); Assert.Equal(CombatTargetsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Targets); 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(); 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); var elite = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcEliteId);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.MaxHp); Assert.Equal(200, elite.MaxHp);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp); Assert.Equal(200, elite.CurrentHp);
Assert.False(alpha.Defeated); Assert.False(elite.Defeated);
var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); var melee = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.MaxHp); Assert.Equal(100, melee.MaxHp);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); Assert.Equal(100, melee.CurrentHp);
Assert.False(beta.Defeated); 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] [Fact]
public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha() public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnMelee()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
await BindSlot0PulseAsync(client); await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client); await LockPrototypeNpcMeleeAsync(client);
var castResponse = await client.PostAsJsonAsync( var castResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast", "/game/players/dev-local-1/ability-cast",
PulseCastRequest()); PulseCastRequest());
@ -107,23 +108,23 @@ public class CombatTargetsWorldApiTests
Assert.Equal(75, castBody.CombatResolution!.TargetRemainingHp); Assert.Equal(75, castBody.CombatResolution!.TargetRemainingHp);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); var melee = body!.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal(75, alpha.CurrentHp); Assert.Equal(75, melee.CurrentHp);
Assert.False(alpha.Defeated); Assert.False(melee.Defeated);
var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); Assert.Equal(80, ranged.CurrentHp);
Assert.False(beta.Defeated); Assert.False(ranged.Defeated);
} }
[Fact] [Fact]
public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnAlpha() public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnMelee()
{ {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock); Assert.NotNull(factory.FakeClock);
await BindSlot0PulseAsync(client); await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client); await LockPrototypeNpcMeleeAsync(client);
var cast = PulseCastRequest(); var cast = PulseCastRequest();
for (var i = 0; i < 4; i++) for (var i = 0; i < 4; i++)
{ {
@ -142,11 +143,11 @@ public class CombatTargetsWorldApiTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CombatTargetsListResponse>(); var body = await response.Content.ReadFromJsonAsync<CombatTargetsListResponse>();
Assert.NotNull(body); Assert.NotNull(body);
var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId); var melee = body!.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
Assert.Equal(0, alpha.CurrentHp); Assert.Equal(0, melee.CurrentHp);
Assert.True(alpha.Defeated); Assert.True(melee.Defeated);
var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId); var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId);
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp); Assert.Equal(80, ranged.CurrentHp);
Assert.False(beta.Defeated); Assert.False(ranged.Defeated);
} }
} }

View File

@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests.Game.PositionState; using NeonSprawl.Server.Tests.Game.PositionState;
@ -27,7 +28,7 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion, SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0, SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse, AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act — defeat alpha through first host (four pulses) // Act — defeat alpha through first host (four pulses)
@ -45,7 +46,7 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
for (var i = 0; i < 4; i++) for (var i = 0; i < 4; i++)
{ {

View File

@ -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
{
/// <summary>Prototype NPC instances in registry id order (ordinal). Keep in sync with Bruno.</summary>
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<INpcBehaviorDefinitionRegistry>();
// 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);
}
}
}

View File

@ -0,0 +1,51 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Tests.Game.Npc;
/// <summary>Shared prototype NPC behavior + health store fixtures for unit tests (NEO-91).</summary>
public static class PrototypeNpcTestFixtures
{
public static INpcBehaviorDefinitionRegistry CreateBehaviorRegistry()
{
var byId = new Dictionary<string, NpcBehaviorDefRow>(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());
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using Xunit; 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). // 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); var snap = new PositionSnapshot(3, 0, -3, 0);
// Act // Act
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap); var validity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap);
// Assert // Assert
Assert.Equal(TargetValidity.Ok, validity); Assert.Equal(TargetValidity.Ok, validity);
} }
@ -50,23 +51,24 @@ public class PlayerTargetStateReaderTests
const double eps = 1e-6; const double eps = 1e-6;
var snap = new PositionSnapshot(3 + eps, 0, -3, 0); var snap = new PositionSnapshot(3 + eps, 0, -3, 0);
// Act // Act
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap); var validity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap);
// Assert // Assert
Assert.Equal(TargetValidity.OutOfRange, validity); Assert.Equal(TargetValidity.OutOfRange, validity);
} }
[Fact] [Fact]
public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets() public void ComputeValidity_AtOrigin_ShouldReturnOk_ForMeleeRangedAndElite()
{ {
// Arrange // Arrange
// NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can // Melee (-3,-3) r=6, ranged (3,3) r=6, elite (0,0) r=6 overlap at origin for tab cycle.
// flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m).
var snap = new PositionSnapshot(0, 0, 0, 0); var snap = new PositionSnapshot(0, 0, 0, 0);
// Act // Act
var alphaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap); var meleeValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap);
var betaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap); var rangedValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcRangedId, in snap);
var eliteValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcEliteId, in snap);
// Assert // Assert
Assert.Equal(TargetValidity.Ok, alphaValidity); Assert.Equal(TargetValidity.Ok, meleeValidity);
Assert.Equal(TargetValidity.Ok, betaValidity); Assert.Equal(TargetValidity.Ok, rangedValidity);
Assert.Equal(TargetValidity.Ok, eliteValidity);
} }
} }

View File

@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using Xunit; using Xunit;
@ -45,7 +46,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -54,7 +55,7 @@ public class TargetingApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.SelectionApplied); Assert.True(body!.SelectionApplied);
Assert.Null(body.ReasonCode); 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(TargetValidity.Ok, body.TargetState.Validity);
Assert.Equal(1, body.TargetState.Sequence); Assert.Equal(1, body.TargetState.Sequence);
} }
@ -100,7 +101,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
}); });
// Assert // Assert
@ -128,7 +129,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 }, PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 },
}); });
// Assert // Assert
@ -138,7 +139,7 @@ public class TargetingApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.SelectionApplied); Assert.True(body!.SelectionApplied);
Assert.Null(body.ReasonCode); Assert.Null(body.ReasonCode);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetBetaId, body.TargetState.LockedTargetId); Assert.Equal(PrototypeNpcRegistry.PrototypeNpcRangedId, body.TargetState.LockedTargetId);
} }
[Fact] [Fact]
@ -156,7 +157,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
PositionHint = new PositionVector { X = 50.0, Y = 0.0, Z = 50.0 }, PositionHint = new PositionVector { X = 50.0, Y = 0.0, Z = 50.0 },
}); });
// Assert // Assert
@ -184,7 +185,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId, TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 }, PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 },
}); });
@ -213,7 +214,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
var clear = await client.PostAsync( var clear = await client.PostAsync(
@ -247,7 +248,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
var move = new MoveCommandRequest var move = new MoveCommandRequest
@ -265,7 +266,7 @@ public class TargetingApiTests
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, get.StatusCode); Assert.Equal(HttpStatusCode.OK, get.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId); Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body!.LockedTargetId);
Assert.Equal(TargetValidity.OutOfRange, body.Validity); Assert.Equal(TargetValidity.OutOfRange, body.Validity);
} }
@ -297,7 +298,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -318,7 +319,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = 999, SchemaVersion = 999,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -360,7 +361,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = " PROTOTYPE_TARGET_ALPHA ", TargetId = " PROTOTYPE_NPC_MELEE ",
}); });
// Assert // Assert
@ -368,7 +369,7 @@ public class TargetingApiTests
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>(); var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.SelectionApplied); Assert.True(body!.SelectionApplied);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId); Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId);
} }
[Fact] [Fact]
@ -382,7 +383,7 @@ public class TargetingApiTests
var req = new TargetSelectRequest var req = new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}; };
// Act // Act
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req); var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
@ -407,7 +408,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
var move = new MoveCommandRequest var move = new MoveCommandRequest
@ -423,7 +424,7 @@ public class TargetingApiTests
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
}); });
// Assert // Assert
@ -432,7 +433,7 @@ public class TargetingApiTests
Assert.NotNull(body); Assert.NotNull(body);
Assert.False(body!.SelectionApplied); Assert.False(body!.SelectionApplied);
Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode); 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); Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity);
} }
} }

View File

@ -2,6 +2,7 @@ using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Gigs;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -149,7 +150,7 @@ public static class AbilityCastApi
} }
var lookupKey = body.TargetId.Trim().ToLowerInvariant(); 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). // NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return JsonAbilityCast( return JsonAbilityCast(

View File

@ -1,4 +1,4 @@
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
@ -16,7 +16,7 @@ public static class CombatTargetFixtureApi
return Results.BadRequest(); return Results.BadRequest();
} }
foreach (var id in PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder()) foreach (var id in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{ {
if (!healthStore.TryResetToFull(id, out _)) if (!healthStore.TryResetToFull(id, out _))
{ {

View File

@ -1,4 +1,4 @@
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
@ -11,7 +11,7 @@ public static class CombatTargetsWorldApi
"/game/world/combat-targets", "/game/world/combat-targets",
(ICombatEntityHealthStore healthStore) => (ICombatEntityHealthStore healthStore) =>
{ {
var ids = PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder(); var ids = PrototypeNpcRegistry.GetInstanceIdsInOrder();
var targets = new List<CombatTargetJson>(ids.Count); var targets = new List<CombatTargetJson>(ids.Count);
foreach (var id in ids) foreach (var id in ids)
{ {

View File

@ -1,8 +1,8 @@
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
/// <summary> /// <summary>
/// Server-owned HP store for prototype combat dummies keyed by /// Server-owned HP store for prototype NPC combat instances keyed by
/// <see cref="Targeting.PrototypeTargetRegistry"/> target id (NEO-80). /// <see cref="Npc.PrototypeNpcRegistry"/> instance id (NEO-80, NEO-91).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para><b>NEO-81+:</b> combat resolution should depend on this interface rather than reaching into store internals.</para> /// <para><b>NEO-81+:</b> combat resolution should depend on this interface rather than reaching into store internals.</para>
@ -11,8 +11,8 @@ namespace NeonSprawl.Server.Game.Combat;
public interface ICombatEntityHealthStore public interface ICombatEntityHealthStore
{ {
/// <summary> /// <summary>
/// Reads the current snapshot for a known prototype target. Lazy-initializes HP to /// Reads the current snapshot for a known prototype NPC instance. Lazy-initializes HP to
/// <see cref="PrototypeCombatConstants.MaxPrototypeTargetHp"/> on first access. /// the bound behavior def <c>maxHp</c> on first access.
/// </summary> /// </summary>
bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot); bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot);

View File

@ -1,10 +1,10 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.Combat; namespace NeonSprawl.Server.Game.Combat;
/// <summary>Thread-safe in-memory HP for prototype combat dummies; empty at startup — lazy rows only (NEO-80).</summary> /// <summary>Thread-safe in-memory HP for prototype NPC combat instances; empty at startup — lazy rows only (NEO-80, NEO-91).</summary>
public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore public sealed class InMemoryCombatEntityHealthStore(INpcBehaviorDefinitionRegistry behaviorRegistry) : ICombatEntityHealthStore
{ {
private readonly ConcurrentDictionary<string, int> currentHpById = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, int> currentHpById = new(StringComparer.Ordinal);
@ -14,7 +14,7 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
public bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot) public bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot)
{ {
var key = NormalizeTargetId(targetId); var key = NormalizeTargetId(targetId);
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp))
{ {
snapshot = default; snapshot = default;
return false; return false;
@ -22,8 +22,8 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
lock (idLocks.GetOrAdd(key, _ => new object())) lock (idLocks.GetOrAdd(key, _ => new object()))
{ {
EnsureRowInitialized(key); EnsureRowInitialized(key, maxHp);
snapshot = CreateSnapshot(key, currentHpById[key]); snapshot = CreateSnapshot(key, maxHp, currentHpById[key]);
return true; return true;
} }
} }
@ -38,18 +38,18 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
} }
var key = NormalizeTargetId(targetId); var key = NormalizeTargetId(targetId);
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp))
{ {
return false; return false;
} }
lock (idLocks.GetOrAdd(key, _ => new object())) lock (idLocks.GetOrAdd(key, _ => new object()))
{ {
EnsureRowInitialized(key); EnsureRowInitialized(key, maxHp);
var current = currentHpById[key]; var current = currentHpById[key];
var next = Math.Max(0, current - damage); var next = Math.Max(0, current - damage);
currentHpById[key] = next; currentHpById[key] = next;
snapshot = CreateSnapshot(key, next); snapshot = CreateSnapshot(key, maxHp, next);
return true; return true;
} }
} }
@ -58,7 +58,7 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
public bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot) public bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot)
{ {
var key = NormalizeTargetId(targetId); var key = NormalizeTargetId(targetId);
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _)) if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp))
{ {
snapshot = default; snapshot = default;
return false; return false;
@ -66,24 +66,41 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
lock (idLocks.GetOrAdd(key, _ => new object())) lock (idLocks.GetOrAdd(key, _ => new object()))
{ {
currentHpById[key] = PrototypeCombatConstants.MaxPrototypeTargetHp; currentHpById[key] = maxHp;
snapshot = CreateSnapshot(key, PrototypeCombatConstants.MaxPrototypeTargetHp); snapshot = CreateSnapshot(key, maxHp, maxHp);
return true; 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)) 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( new(
targetId, targetId,
PrototypeCombatConstants.MaxPrototypeTargetHp, maxHp,
currentHp, currentHp,
currentHp <= 0); currentHp <= 0);

View File

@ -1,11 +0,0 @@
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Frozen prototype combat tuning for E5.M1 Slice 1 (NEO-80).</summary>
public static class PrototypeCombatConstants
{
/// <summary>
/// Shared max HP for <see cref="Targeting.PrototypeTargetRegistry"/> combat dummies.
/// Four <c>prototype_pulse</c> casts at 25 damage each defeat one dummy (100 HP).
/// </summary>
public const int MaxPrototypeTargetHp = 100;
}

View File

@ -0,0 +1,71 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
/// Prototype NPC combat instance ids → behavior binding + world anchor + horizontal lock radius (NEO-91).
/// Keys are lowercase. Replaces <c>PrototypeTargetRegistry</c> alpha/beta dummies.
/// </summary>
public static class PrototypeNpcRegistry
{
/// <summary>
/// All prototype NPC instances share a single <c>LockRadius</c>. Changing this requires updating
/// <c>client/scripts/prototype_target_constants.gd</c> in NEO-97 so markers/HUD match server range checks.
/// </summary>
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";
/// <summary>Former alpha anchor; overlaps elite at origin for tab cycle (NEO-24 pattern).</summary>
public static readonly PrototypeNpcInstanceEntry PrototypeNpcMelee = new(
PrototypeNpcBehaviorRegistry.PrototypeMeleePressure,
-3,
0.5,
-3,
SharedLockRadius);
/// <summary>Former beta anchor; mirrors melee across the origin.</summary>
public static readonly PrototypeNpcInstanceEntry PrototypeNpcRanged = new(
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
3,
0,
3,
SharedLockRadius);
/// <summary>Center anchor; overlaps melee/ranged rings at origin (~4.24 m from each).</summary>
public static readonly PrototypeNpcInstanceEntry PrototypeNpcElite = new(
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
0,
0.5,
0,
SharedLockRadius);
private static readonly Dictionary<string, PrototypeNpcInstanceEntry> ById = new(StringComparer.Ordinal)
{
[PrototypeNpcMeleeId] = PrototypeNpcMelee,
[PrototypeNpcRangedId] = PrototypeNpcRanged,
[PrototypeNpcEliteId] = PrototypeNpcElite,
};
public static bool TryGet(string instanceIdLowercase, out PrototypeNpcInstanceEntry entry) =>
ById.TryGetValue(instanceIdLowercase, out entry);
/// <summary>All prototype NPC instance ids in ascending ordinal order (NEO-83 / NEO-91).</summary>
public static IReadOnlyList<string> GetInstanceIdsInOrder()
{
var ids = ById.Keys.ToList();
ids.Sort(StringComparer.Ordinal);
return ids;
}
}
/// <param name="BehaviorDefId">Frozen behavior catalog id bound to this instance.</param>
/// <param name="LockRadius">Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</param>
public readonly record struct PrototypeNpcInstanceEntry(
string BehaviorDefId,
double X,
double Y,
double Z,
double LockRadius);

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -13,7 +14,7 @@ public static class PlayerTargetStateReader
return TargetValidity.None; return TargetValidity.None;
} }
if (!PrototypeTargetRegistry.TryGet(lockIdLowercase, out var entry)) if (!PrototypeNpcRegistry.TryGet(lockIdLowercase, out var entry))
{ {
return TargetValidity.InvalidTarget; return TargetValidity.InvalidTarget;
} }

View File

@ -1,44 +0,0 @@
namespace NeonSprawl.Server.Game.Targeting;
/// <summary>Prototype combat target ids → world anchor + horizontal lock radius (NEO-23). Keys are lowercase.</summary>
public static class PrototypeTargetRegistry
{
/// <summary>
/// All prototype combat targets share a single <c>LockRadius</c>. Scoped to prototype
/// stubs — per-target radii will come back with real combat design (E5.M1). Changing
/// this requires updating <c>client/scripts/prototype_target_constants.gd</c> in the
/// same commit so markers/HUD match server range checks.
/// </summary>
public const double SharedLockRadius = 6.0;
/// <summary>On the -x/-z side of spawn; overlaps <see cref="PrototypeTargetBeta"/>'s ring at origin so Tab can flip without moving.</summary>
public const string PrototypeTargetAlphaId = "prototype_target_alpha";
/// <summary>On the +x/+z side; mirrors alpha across the origin for symmetric overlap (NEO-24 Tab cycle).</summary>
public const string PrototypeTargetBetaId = "prototype_target_beta";
/// <summary>Alpha anchor; <see cref="PrototypeTargetEntry.LockRadius"/> is XZ-only. Distance to beta ~8.49 m ⇒ ~3.5 m overlap at origin.</summary>
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<string, PrototypeTargetEntry> ById = new(StringComparer.Ordinal)
{
[PrototypeTargetAlphaId] = PrototypeTargetAlpha,
[PrototypeTargetBetaId] = PrototypeTargetBeta,
};
public static bool TryGet(string targetIdLowercase, out PrototypeTargetEntry entry) =>
ById.TryGetValue(targetIdLowercase, out entry);
/// <summary>All prototype combat target ids in ascending ordinal order (NEO-83).</summary>
public static IReadOnlyList<string> GetPrototypeTargetIdsInOrder()
{
var ids = ById.Keys.ToList();
ids.Sort(StringComparer.Ordinal);
return ids;
}
}
/// <param name="LockRadius">Horizontal reach on X/Z; inclusive boundary (same as NEO-9).</param>
public readonly record struct PrototypeTargetEntry(double X, double Y, double Z, double LockRadius);

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World; using NeonSprawl.Server.Game.World;
@ -64,7 +65,7 @@ public static class TargetingApi
} }
var lookupKey = trimmed.ToLowerInvariant(); 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 (lockUnknown, seqUnknown) = locks.GetLockState(id);
var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap); var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap);

View File

@ -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" 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 | | 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`). | | **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**. | | **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 | | Field | Meaning |
|-------|---------| |-------|---------|
| **`targetId`** | Lowercase prototype registry id (`prototype_target_alpha`, `prototype_target_beta`). | | **`targetId`** | Lowercase NPC instance id (`prototype_npc_elite`, `prototype_npc_melee`, `prototype_npc_ranged`). |
| **`maxHp`** | Frozen prototype max (**100**). | | **`maxHp`** | Catalog max from bound behavior def (200 / 100 / 80). |
| **`currentHp`** | Authoritative HP after cast-applied damage; floored at zero. | | **`currentHp`** | Authoritative HP after cast-applied damage; floored at zero. |
| **`defeated`** | **`true`** when **`currentHp`** is **0**. | | **`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" 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) ## Combat engine (NEO-81)
@ -159,7 +161,7 @@ curl -sS -i "http://localhost:5253/game/world/combat-targets"
| Reason code | When | | Reason code | When |
|-------------|------| |-------------|------|
| **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). | | **`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). | | **`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. **`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)). **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`** **`GET /game/players/{id}/target`**
@ -565,7 +567,7 @@ curl -s http://localhost:5253/game/players/dev-local-1/target
```bash ```bash
curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \ curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}' -d '{"schemaVersion":1,"targetId":"prototype_npc_melee"}'
``` ```
**HTTP** **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`**. 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`):** **Accept payload (`combatResolution`, present only when `accepted: true`):**