Merge pull request #128 from ViPro-Technologies/NEO-91-npc-instance-registry-combat-target-migration
NEO-91: Prototype NPC instance registry + combat-target migrationpull/129/head
commit
26ab5249c5
|
|
@ -27,7 +27,7 @@ script:pre-request {
|
|||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||
{ schemaVersion: 1, targetId: "prototype_target_beta" },
|
||||
{ schemaVersion: 1, targetId: "prototype_npc_ranged" },
|
||||
jsonHeaders,
|
||||
);
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ body:json {
|
|||
"schemaVersion": 1,
|
||||
"slotIndex": 0,
|
||||
"abilityId": "prototype_pulse",
|
||||
"targetId": "prototype_target_beta"
|
||||
"targetId": "prototype_npc_ranged"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,9 +60,9 @@ tests {
|
|||
expect(body.accepted).to.equal(true);
|
||||
expect(body.combatResolution).to.be.an("object");
|
||||
expect(body.combatResolution.abilityId).to.equal("prototype_pulse");
|
||||
expect(body.combatResolution.targetId).to.equal("prototype_target_beta");
|
||||
expect(body.combatResolution.targetId).to.equal("prototype_npc_ranged");
|
||||
expect(body.combatResolution.damageDealt).to.equal(25);
|
||||
expect(body.combatResolution.targetRemainingHp).to.equal(75);
|
||||
expect(body.combatResolution.targetRemainingHp).to.equal(55);
|
||||
expect(body.combatResolution.targetDefeated).to.equal(false);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks alpha, casts pulse four times (3.2s between), fifth request expects target_defeated deny.
|
||||
NEO-82 spine: slot 1 (slot 0 may be cooling from happy seq 22). Pre-request locks prototype_npc_melee, casts pulse four times (3.2s between), fifth request expects target_defeated deny.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
@ -35,7 +35,7 @@ script:pre-request {
|
|||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||
{ schemaVersion: 1, targetId: "prototype_target_alpha" },
|
||||
{ schemaVersion: 1, targetId: "prototype_npc_melee" },
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ script:pre-request {
|
|||
schemaVersion: 1,
|
||||
slotIndex: 1,
|
||||
abilityId: "prototype_pulse",
|
||||
targetId: "prototype_target_alpha",
|
||||
targetId: "prototype_npc_melee",
|
||||
};
|
||||
|
||||
const castResults = [];
|
||||
|
|
@ -80,7 +80,7 @@ body:json {
|
|||
"schemaVersion": 1,
|
||||
"slotIndex": 1,
|
||||
"abilityId": "prototype_pulse",
|
||||
"targetId": "prototype_target_alpha"
|
||||
"targetId": "prototype_npc_melee"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
Resets authoritative position to `Game:DefaultPosition` (~-5, 0.9, -5) so `prototype_target_alpha` lock and cast range checks pass when the collection ran earlier requests that moved the player (e.g. seq 10–11 resource-node gather). Runs before hotbar/target/cast (seq 20–22).
|
||||
Resets authoritative position to `Game:DefaultPosition` (~-5, 0.9, -5) so `prototype_npc_melee` lock and cast range checks pass when the collection ran earlier requests that moved the player (e.g. seq 10–11 resource-node gather). Runs before hotbar/target/cast (seq 20–22).
|
||||
}
|
||||
|
||||
post {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
meta {
|
||||
name: POST target select alpha (cast preset)
|
||||
name: POST target select melee (cast preset)
|
||||
type: http
|
||||
seq: 21
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ post {
|
|||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"targetId": "prototype_target_alpha"
|
||||
"targetId": "prototype_npc_melee"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +36,6 @@ tests {
|
|||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.selectionApplied).to.equal(true);
|
||||
expect(body.targetState.lockedTargetId).to.equal("prototype_target_alpha");
|
||||
expect(body.targetState.lockedTargetId).to.equal("prototype_npc_melee");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-82: happy cast uses beta on slot 0; defeat spine uses slot 1 on alpha (~13s pre-request).
|
||||
NEO-82: happy cast uses prototype_npc_ranged on slot 0; defeat spine uses slot 1 on prototype_npc_melee (~13s pre-request).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-83 AC spine: slot 1 (seq 2 one-pulse uses slot 0 and leaves cooldown). Pre-request locks alpha, casts pulse four times (3.2s between), then GET asserts alpha defeated.
|
||||
NEO-83 AC spine: slot 1 (seq 2 one-pulse uses slot 0 and leaves cooldown). Pre-request locks prototype_npc_melee, casts pulse four times (3.2s between), then GET asserts melee defeated.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
@ -38,7 +38,7 @@ script:pre-request {
|
|||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||
{ schemaVersion: 1, targetId: "prototype_target_alpha" },
|
||||
{ schemaVersion: 1, targetId: "prototype_npc_melee" },
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ script:pre-request {
|
|||
schemaVersion: 1,
|
||||
slotIndex: 1,
|
||||
abilityId: "prototype_pulse",
|
||||
targetId: "prototype_target_alpha",
|
||||
targetId: "prototype_npc_melee",
|
||||
};
|
||||
|
||||
const castResults = [];
|
||||
|
|
@ -82,7 +82,7 @@ tests {
|
|||
expect(res.getStatus()).to.equal(200);
|
||||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.targets.length).to.equal(2);
|
||||
expect(body.targets.length).to.equal(3);
|
||||
});
|
||||
|
||||
test("cast results stepped HP before GET", function () {
|
||||
|
|
@ -95,24 +95,24 @@ tests {
|
|||
expect(castResults[3].combatResolution.targetDefeated).to.equal(true);
|
||||
});
|
||||
|
||||
test("alpha reflects cast damage authoritatively", function () {
|
||||
test("melee reflects cast damage authoritatively", function () {
|
||||
const body = res.getBody();
|
||||
const alpha = body.targets.find(
|
||||
(x) => x.targetId === "prototype_target_alpha",
|
||||
const melee = body.targets.find(
|
||||
(x) => x.targetId === "prototype_npc_melee",
|
||||
);
|
||||
expect(alpha).to.be.an("object");
|
||||
expect(alpha.currentHp).to.equal(0);
|
||||
expect(alpha.maxHp).to.equal(100);
|
||||
expect(alpha.defeated).to.equal(true);
|
||||
expect(melee).to.be.an("object");
|
||||
expect(melee.currentHp).to.equal(0);
|
||||
expect(melee.maxHp).to.equal(100);
|
||||
expect(melee.defeated).to.equal(true);
|
||||
});
|
||||
|
||||
test("beta unchanged after alpha-only casts", function () {
|
||||
test("ranged unchanged after melee-only casts", function () {
|
||||
const body = res.getBody();
|
||||
const beta = body.targets.find(
|
||||
(x) => x.targetId === "prototype_target_beta",
|
||||
const ranged = body.targets.find(
|
||||
(x) => x.targetId === "prototype_npc_ranged",
|
||||
);
|
||||
expect(beta).to.be.an("object");
|
||||
expect(beta.currentHp).to.equal(100);
|
||||
expect(beta.defeated).to.equal(false);
|
||||
expect(ranged).to.be.an("object");
|
||||
expect(ranged.currentHp).to.equal(80);
|
||||
expect(ranged.defeated).to.equal(false);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
Fast cast→GET smoke: one pulse on alpha, no cooldown waits. Four-pulse defeat spine is seq 3.
|
||||
Fast cast→GET smoke: one pulse on prototype_npc_melee, no cooldown waits. Four-pulse defeat spine is seq 3.
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
|
|
@ -34,7 +34,7 @@ script:pre-request {
|
|||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||
{ schemaVersion: 1, targetId: "prototype_target_alpha" },
|
||||
{ schemaVersion: 1, targetId: "prototype_npc_melee" },
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ script:pre-request {
|
|||
schemaVersion: 1,
|
||||
slotIndex: 0,
|
||||
abilityId: "prototype_pulse",
|
||||
targetId: "prototype_target_alpha",
|
||||
targetId: "prototype_npc_melee",
|
||||
},
|
||||
jsonHeaders,
|
||||
);
|
||||
|
|
@ -70,24 +70,25 @@ tests {
|
|||
expect(castBody.combatResolution.targetDefeated).to.equal(false);
|
||||
});
|
||||
|
||||
test("GET alpha reflects cast damage authoritatively", function () {
|
||||
test("GET melee reflects cast damage authoritatively", function () {
|
||||
const body = res.getBody();
|
||||
const alpha = body.targets.find(
|
||||
(x) => x.targetId === "prototype_target_alpha",
|
||||
const melee = body.targets.find(
|
||||
(x) => x.targetId === "prototype_npc_melee",
|
||||
);
|
||||
expect(alpha).to.be.an("object");
|
||||
expect(alpha.currentHp).to.equal(75);
|
||||
expect(alpha.maxHp).to.equal(100);
|
||||
expect(alpha.defeated).to.equal(false);
|
||||
expect(melee).to.be.an("object");
|
||||
expect(melee.currentHp).to.equal(75);
|
||||
expect(melee.maxHp).to.equal(100);
|
||||
expect(melee.defeated).to.equal(false);
|
||||
});
|
||||
|
||||
test("beta unchanged after alpha-only cast", function () {
|
||||
test("ranged unchanged after melee-only cast", function () {
|
||||
const body = res.getBody();
|
||||
const beta = body.targets.find(
|
||||
(x) => x.targetId === "prototype_target_beta",
|
||||
const ranged = body.targets.find(
|
||||
(x) => x.targetId === "prototype_npc_ranged",
|
||||
);
|
||||
expect(beta).to.be.an("object");
|
||||
expect(beta.currentHp).to.equal(100);
|
||||
expect(beta.defeated).to.equal(false);
|
||||
expect(ranged).to.be.an("object");
|
||||
expect(ranged.currentHp).to.equal(80);
|
||||
expect(ranged.maxHp).to.equal(80);
|
||||
expect(ranged.defeated).to.equal(false);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ tests {
|
|||
const body = res.getBody();
|
||||
expect(body.schemaVersion).to.equal(1);
|
||||
expect(body.targets).to.be.an("array");
|
||||
expect(body.targets.length).to.equal(2);
|
||||
expect(body.targets.length).to.equal(3);
|
||||
});
|
||||
|
||||
test("targets are ascending by targetId (ordinal)", function () {
|
||||
|
|
@ -32,21 +32,27 @@ tests {
|
|||
expect(ids).to.eql(sorted);
|
||||
});
|
||||
|
||||
test("prototype registry targets match id order", function () {
|
||||
test("prototype NPC registry targets match id order", function () {
|
||||
const body = res.getBody();
|
||||
const ids = body.targets.map((x) => x.targetId);
|
||||
expect(ids).to.eql([
|
||||
"prototype_target_alpha",
|
||||
"prototype_target_beta",
|
||||
"prototype_npc_elite",
|
||||
"prototype_npc_melee",
|
||||
"prototype_npc_ranged",
|
||||
]);
|
||||
});
|
||||
|
||||
test("both targets start at full HP on fresh server", function () {
|
||||
test("all targets start at catalog max HP on fresh server", function () {
|
||||
const body = res.getBody();
|
||||
for (const row of body.targets) {
|
||||
expect(row.maxHp).to.equal(100);
|
||||
expect(row.currentHp).to.equal(100);
|
||||
expect(row.defeated).to.equal(false);
|
||||
}
|
||||
const byId = Object.fromEntries(body.targets.map((x) => [x.targetId, x]));
|
||||
expect(byId.prototype_npc_elite.maxHp).to.equal(200);
|
||||
expect(byId.prototype_npc_elite.currentHp).to.equal(200);
|
||||
expect(byId.prototype_npc_elite.defeated).to.equal(false);
|
||||
expect(byId.prototype_npc_melee.maxHp).to.equal(100);
|
||||
expect(byId.prototype_npc_melee.currentHp).to.equal(100);
|
||||
expect(byId.prototype_npc_melee.defeated).to.equal(false);
|
||||
expect(byId.prototype_npc_ranged.maxHp).to.equal(80);
|
||||
expect(byId.prototype_npc_ranged.currentHp).to.equal(80);
|
||||
expect(byId.prototype_npc_ranged.defeated).to.equal(false);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ meta {
|
|||
}
|
||||
|
||||
docs {
|
||||
NEO-44 AC spine: pre-request resets prototype target HP (NEO-83 fixture), then four pulse casts on alpha (slot 2 — avoids slot 0/1 cooldown from ability-cast and combat-targets folders).
|
||||
NEO-44 AC spine: pre-request resets prototype NPC combat HP (NEO-83 fixture), then four pulse casts on prototype_npc_melee (slot 2 — avoids slot 0/1 cooldown from ability-cast and combat-targets folders).
|
||||
Asserts breach xp increased by 25 from pre-spine baseline; salvage/refine skill xp unchanged.
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ script:pre-request {
|
|||
|
||||
await axios.post(
|
||||
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||
{ schemaVersion: 1, targetId: "prototype_target_alpha" },
|
||||
{ schemaVersion: 1, targetId: "prototype_npc_melee" },
|
||||
jsonHeaders,
|
||||
);
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ script:pre-request {
|
|||
schemaVersion: 1,
|
||||
slotIndex: 2,
|
||||
abilityId: "prototype_pulse",
|
||||
targetId: "prototype_target_alpha",
|
||||
targetId: "prototype_npc_melee",
|
||||
};
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
|
|
@ -100,7 +100,7 @@ script:post-response {
|
|||
}
|
||||
|
||||
tests {
|
||||
test("breach gig xp increases by 25 after alpha defeat", function () {
|
||||
test("breach gig xp increases by 25 after melee defeat", function () {
|
||||
const body = res.getBody();
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
expect(body.mainGigId).to.equal("breach");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
meta {
|
||||
name: POST target select - prototype_target_alpha
|
||||
name: POST target select - prototype_npc_melee
|
||||
type: http
|
||||
seq: 11
|
||||
}
|
||||
|
|
@ -17,6 +17,6 @@ headers {
|
|||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"targetId": "prototype_target_alpha"
|
||||
"targetId": "prototype_npc_melee"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
meta {
|
||||
name: POST target select - prototype_target_beta (with positionHint)
|
||||
name: POST target select - prototype_npc_ranged (with positionHint)
|
||||
type: http
|
||||
seq: 13
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ headers {
|
|||
body:json {
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"targetId": "prototype_target_beta",
|
||||
"targetId": "prototype_npc_ranged",
|
||||
"positionHint": { "x": 3.0, "y": 0.5, "z": 5.0 }
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,6 @@ body:json {
|
|||
docs {
|
||||
NEO-24 follow-up #5: the optional `positionHint` lets the server run the radius check against
|
||||
the client's live capsule position instead of the stored `move-stream` snap. Useful to verify
|
||||
a "stopped near beta but store is stale" case without having to drive the capsule manually.
|
||||
a "stopped near prototype_npc_ranged but store is stale" case without having to drive the capsule manually.
|
||||
Server treats the hint as advisory — it is NOT written to the position store.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
| **Module ID** | E5.M2 |
|
||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||
| **Stage target** | Prototype |
|
||||
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
@ -81,6 +81,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
|
|||
|
||||
**NPC behavior definition registry (NEO-89):** **`INpcBehaviorDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Npc/` wraps the startup-loaded catalog for read-only lookup by stable behavior `id` (`TryGetDefinition`), trim/lowercase validation (`TryNormalizeKnown`), and ordered enumeration (`GetDefinitionsInIdOrder`). Stable prototype id constants: **`PrototypeNpcBehaviorRegistry`**. **NEO-91+** (instance registry, runtime tick) should inject the interface rather than `NpcBehaviorDefinitionCatalog`. Plan: [NEO-89 implementation plan](../../plans/NEO-89-implementation-plan.md).
|
||||
|
||||
**NPC instance registry (NEO-91):** **`PrototypeNpcRegistry`** in `server/NeonSprawl.Server/Game/Npc/` — instance ids **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** with behavior bindings + world anchors; **`ICombatEntityHealthStore`** uses catalog **`maxHp`** per instance. Plan: [NEO-91 implementation plan](../../plans/NEO-91-implementation-plan.md). **Breaking:** Godot constants migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97).
|
||||
|
||||
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||
|
||||
## Risks and telemetry
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -76,9 +76,9 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
| E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned |
|
||||
| E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned |
|
||||
|
||||
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy prototype dummy HP at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)**; DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80)](../../../server/README.md#combat-entity-health-neo-80). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 complete — register row Ready.**
|
||||
**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-implementation-plan.md)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)). **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78 plan](../../plans/NEO-78-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91 plan](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80 plan](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny vocabulary (`target_defeated`, `unknown_ability`, `unknown_target`) ([NEO-81 plan](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** invokes **`CombatOperations.TryResolve`** after E1.M4 gates; nested wire **`combatResolution`** on accept; per-ability catalog **`cooldownSeconds`** on successful resolve; **`target_defeated`** JSON cast deny without cooldown ([NEO-82 plan](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** for client HUD ([NEO-83 plan](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44 plan](../../plans/NEO-44-implementation-plan.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44). **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84 plan](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`combat_targets_client.gd`**, **`CombatTargetHpLabel`**, **`CastFeedbackLabel`** resolution copy; event-driven **`GET /game/world/combat-targets`** refresh ([NEO-85 plan](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); [client README — Combat feedback + target HP HUD (NEO-85)](../../../client/README.md#combat-feedback--target-hp-hud-neo-85). **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md) ([NEO-86 plan](../../plans/NEO-86-implementation-plan.md)); [client README — End-to-end combat loop (NEO-86)](../../../client/README.md#end-to-end-combat-loop-neo-86). **Epic 5 Slice 1 complete — register row Ready.**
|
||||
|
||||
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
|
||||
**E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids, per-archetype catalog max HP ([NEO-91 plan](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**.
|
||||
|
||||
### Epic 6 — PvP Security
|
||||
|
||||
|
|
|
|||
|
|
@ -190,9 +190,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
|
|||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed).
|
||||
- [ ] Each instance references a valid frozen `behaviorDefId`.
|
||||
- [ ] Bruno defeat spine passes for at least one archetype.
|
||||
- [x] Cast + combat-targets GET operate on three NPC ids only (alpha/beta removed).
|
||||
- [x] Each instance references a valid frozen `behaviorDefId`.
|
||||
- [x] Bruno defeat spine passes for at least one archetype.
|
||||
|
||||
**Landed ([NEO-91](https://linear.app/neon-sprawl/issue/NEO-91)):** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog; plan [NEO-91-implementation-plan.md](NEO-91-implementation-plan.md).
|
||||
|
||||
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — archetype markers + telegraph HUD (after runtime snapshot lands).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
# NEO-91 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-91 |
|
||||
| **Title** | E5M2-05: Prototype NPC instance registry + combat-target migration |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-91/e5m2-05-prototype-npc-instance-registry-combat-target-migration |
|
||||
| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-05** |
|
||||
| **Branch** | `NEO-91-npc-instance-registry-combat-target-migration` |
|
||||
| **Precursor** | [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — `INpcBehaviorDefinitionRegistry` + DI (**Done** on `main`) |
|
||||
| **Pattern** | [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — combat-targets GET + `PrototypeTargetRegistry`; [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — injectable behavior registry |
|
||||
| **Client counterpart** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — Godot archetype markers + tab cycle (**out of scope** here; **breaking id change** must land in NEO-97 before playable Godot verification) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Registry shape** | New `PrototypeNpcRegistry` vs extend `PrototypeTargetRegistry`? | **New `PrototypeNpcRegistry` in `Game/Npc/`** — targeting/combat consume it; remove alpha/beta ids. | **User:** new `PrototypeNpcRegistry`. |
|
||||
| **World anchors** | Where to place three NPC instances? | **Melee at alpha `(-3, 0.5, -3)`, ranged at beta `(3, 0, 3)`, elite at origin `(0, 0.5, 0)`; `SharedLockRadius` 6.0.** | **User:** preserve alpha/beta + elite at origin. |
|
||||
| **Behavior binding** | Which behavior def per instance? | **1:1:** melee→`prototype_melee_pressure`, ranged→`prototype_ranged_control`, elite→`prototype_elite_mini_boss`. | **Adopted** (frozen naming in E5.M2). |
|
||||
| **Bruno defeat spine** | Which NPC for defeat AC? | **`prototype_npc_melee`** (100 HP, 4× pulse — alpha replacement). | **User:** melee. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Replace passive **`prototype_target_alpha` / `prototype_target_beta`** dummies with **three** NPC combat instances wired into targeting, cast resolution, and the combat-target HP store — each instance bound to a frozen `behaviorDefId` with **max HP from the behavior catalog**.
|
||||
|
||||
**In scope (from Linear + [E5M2-05](E5M2-prototype-backlog.md#e5m2-05--prototype-npc-instance-registry--combat-target-migration)):**
|
||||
|
||||
- **`PrototypeNpcRegistry`** in `Game/Npc/`: instance id, `behaviorDefId`, world anchor, shared lock radius.
|
||||
- Migrate **`ICombatEntityHealthStore`** gates and max HP from flat 100 → per-instance **`maxHp`** via **`INpcBehaviorDefinitionRegistry`**.
|
||||
- Replace **`PrototypeTargetRegistry`** alpha/beta usage across targeting, cast, combat-targets GET, dev fixture reset.
|
||||
- Update server unit/integration tests and **all Bruno** smokes that reference alpha/beta.
|
||||
- Document **breaking id change** + required client constant migration ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
|
||||
|
||||
**Out of scope (from Linear + backlog):**
|
||||
|
||||
- Behavior tick, aggro, telegraphs ([NEO-92+](E5M2-prototype-backlog.md)).
|
||||
- Godot markers / tab cycle ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
|
||||
- `docs/manual-qa/NEO-91.md` — server-only; Bruno + automated tests (NEO-83/90 pattern).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Cast + combat-targets GET operate on **three NPC ids only** (alpha/beta removed).
|
||||
- [x] Each instance references a valid frozen **`behaviorDefId`** resolvable via **`INpcBehaviorDefinitionRegistry`**.
|
||||
- [x] Bruno defeat spine passes for **`prototype_npc_melee`** (4× pulse → defeated).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`PrototypeNpcRegistry`** (`Game/Npc/`):
|
||||
- Stable id constants: **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`**.
|
||||
- **`SharedLockRadius = 6.0`** (same convention as E1.M3/E5.M1 dummies).
|
||||
- **`PrototypeNpcInstanceEntry`**: `BehaviorDefId`, anchor `X/Y/Z`, `LockRadius`.
|
||||
- Frozen bindings (kickoff):
|
||||
|
||||
| Instance id | `behaviorDefId` | Anchor (X, Y, Z) |
|
||||
|-------------|-----------------|------------------|
|
||||
| `prototype_npc_melee` | `prototype_melee_pressure` | (-3, 0.5, -3) |
|
||||
| `prototype_npc_ranged` | `prototype_ranged_control` | (3, 0, 3) |
|
||||
| `prototype_npc_elite` | `prototype_elite_mini_boss` | (0, 0.5, 0) |
|
||||
|
||||
- **`TryGet(instanceIdLowercase, out entry)`**, **`GetInstanceIdsInOrder()`** (ordinal sort — tab cycle order for future NEO-97).
|
||||
|
||||
2. **Retire `PrototypeTargetRegistry`:** Remove alpha/beta ids and the class (or leave a one-release obsolete shim only if grep shows external consumers — prefer **delete** and update all server references). Targeting/combat import **`PrototypeNpcRegistry`** instead.
|
||||
|
||||
3. **`InMemoryCombatEntityHealthStore`:** Constructor-inject **`INpcBehaviorDefinitionRegistry`**. Gate rows on **`PrototypeNpcRegistry.TryGet`**. Lazy-init **`currentHp`** to behavior **`maxHp`**; **`CreateSnapshot`** / **`TryResetToFull`** use per-instance max from catalog. Remove dependence on **`PrototypeCombatConstants.MaxPrototypeTargetHp`** (delete constant class if unused).
|
||||
|
||||
4. **Targeting + cast paths:** **`PlayerTargetStateReader`**, **`TargetingApi`**, **`AbilityCastApi`** — swap **`PrototypeTargetRegistry.TryGet`** → **`PrototypeNpcRegistry.TryGet`** (range math unchanged).
|
||||
|
||||
5. **Combat-targets GET + fixture:** **`CombatTargetsWorldApi`**, **`CombatTargetFixtureApi`** — enumerate **`PrototypeNpcRegistry.GetInstanceIdsInOrder()`** (expect **3** rows).
|
||||
|
||||
6. **Bruno migration:** Replace every **`prototype_target_alpha`** / **`prototype_target_beta`** with NPC ids. Key updates:
|
||||
- **`combat-targets/`** — assert **3** targets; defeat spine on **`prototype_npc_melee`** (same 4× pulse / 75→50→25→0 stepping).
|
||||
- **`ability-cast/`** — happy cast on **`prototype_npc_ranged`** (beta slot/spine replacement); lock/defeat spines on melee.
|
||||
- **`targeting/`** — select melee/ranged ids.
|
||||
- **`gig-progression/Get gig progression after defeat spine.bru`** — melee defeat.
|
||||
- **`scripts/combat-targets-reset-helper.js`** — unchanged route; resets all registry ids server-side.
|
||||
|
||||
7. **Docs:** **`server/README.md`** — combat targets section lists three NPC ids + per-archetype max HP; note **`PrototypeNpcRegistry`** + breaking change for **`client/scripts/prototype_target_constants.gd`** ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)). Update [E5_M2](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) instance registry bullet and [E5M2-prototype-backlog.md](E5M2-prototype-backlog.md) E5M2-05 checkboxes when landed.
|
||||
|
||||
### Expected snapshot values (fresh server, `prototype_pulse` = 25 damage)
|
||||
|
||||
| Instance | `maxHp` (from behavior def) | After 4× pulse on melee only |
|
||||
|----------|------------------------------|------------------------------|
|
||||
| `prototype_npc_elite` | 200 | 200 / 200 |
|
||||
| `prototype_npc_melee` | 100 | 0 / 100, `defeated: true` |
|
||||
| `prototype_npc_ranged` | 80 | 80 / 80 |
|
||||
|
||||
(GET row order: ascending **`targetId`** — elite, melee, ranged.)
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Npc/PrototypeNpcRegistry.cs` | Frozen three-instance registry: ids, behavior bindings, anchors, lock radius, ordered enumeration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcRegistryTests.cs` | AAA unit tests: three ids in order, valid behavior bindings, anchor/lock radius parity, unknown id fails. |
|
||||
| `docs/plans/NEO-91-implementation-plan.md` | This plan. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Combat/InMemoryCombatEntityHealthStore.cs` | Gate on `PrototypeNpcRegistry`; per-instance max HP from `INpcBehaviorDefinitionRegistry`. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatEntityHealthServiceCollectionExtensions.cs` | Ensure health store resolves with behavior registry dependency (singleton ctor injection). |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatTargetsWorldApi.cs` | Enumerate NPC instance ids from `PrototypeNpcRegistry`. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs` | Reset all three NPC instance rows. |
|
||||
| `server/NeonSprawl.Server/Game/Combat/ICombatEntityHealthStore.cs` | XML remarks: NPC instance ids + catalog max HP (not alpha/beta / flat 100). |
|
||||
| `server/NeonSprawl.Server/Game/Combat/PrototypeCombatConstants.cs` | Remove or narrow — flat 100 no longer authoritative. |
|
||||
| `server/NeonSprawl.Server/Game/Targeting/PlayerTargetStateReader.cs` | Use `PrototypeNpcRegistry.TryGet` for validity/range. |
|
||||
| `server/NeonSprawl.Server/Game/Targeting/TargetingApi.cs` | Target select gate on NPC registry. |
|
||||
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Cast target gate on NPC registry. |
|
||||
| `server/NeonSprawl.Server/Game/Targeting/PrototypeTargetRegistry.cs` | **Delete** (after references migrated). |
|
||||
| `server/README.md` | Document three NPC combat targets, registry, breaking id change, per-archetype HP. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs` | AAA tests for per-archetype max HP, melee/ranged/elite independence. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs` | Three targets, ordinal order, melee defeat spine, per-archetype maxHp assertions. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs` | Replace alpha/beta ids with NPC ids. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs` | Three-instance reset. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs` | NPC id select/range cases. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs` | NPC anchor range checks. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Cast against NPC ids. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/CooldownSnapshotApiTests.cs` | Update target id literals if referenced. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gigs/GigProgressionGrantPersistenceIntegrationTests.cs` | Melee defeat id in integration spine. |
|
||||
| `bruno/neon-sprawl-server/combat-targets/*.bru` | Three-target assertions; melee defeat spine. |
|
||||
| `bruno/neon-sprawl-server/ability-cast/*.bru` | NPC target ids in lock/cast spines. |
|
||||
| `bruno/neon-sprawl-server/targeting/*.bru` | NPC target ids in select requests. |
|
||||
| `bruno/neon-sprawl-server/gig-progression/Get gig progression after defeat spine.bru` | Melee defeat spine. |
|
||||
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | Instance registry landed note (when complete). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — NEO-91 instance registry. |
|
||||
| `docs/plans/E5M2-prototype-backlog.md` | E5M2-05 acceptance checkboxes + landed note when complete. |
|
||||
|
||||
**Explicitly not modified (client — NEO-97):** `client/scripts/prototype_target_constants.gd`, Godot tests, manual QA. Plan documents the required client migration.
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Npc/PrototypeNpcRegistryTests.cs` | Three ids ordinal order; each `behaviorDefId` resolves via host registry; anchors + `SharedLockRadius`; unknown id fails `TryGet`. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatEntityHealthStoreTests.cs` | Lazy init to catalog maxHp (100/80/200); damage/defeat/reset per instance; independence across three rows. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetsWorldApiTests.cs` | GET returns 3 targets in id order with correct maxHp; one pulse on melee → 75/100; four pulses → melee defeated, others full. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Combat/CombatOperationsTests.cs` | Resolve against NPC ids (spot-check melee + elite maxHp paths). |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs` | Select melee in range; unknown legacy alpha id → `unknown_target`. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs` | Cast accept/deny with NPC target ids. |
|
||||
|
||||
Bruno **`combat-targets/Get combat targets after cast spine.bru`** is the AC defeat spine (melee, 4× pulse). No `docs/manual-qa/NEO-91.md` (server-only).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|---------------------|--------|
|
||||
| **Linear blockedBy NEO-89** | Proceed — NEO-89 **Done** on `main`. | **adopted** |
|
||||
| **Client breakage until NEO-97** | Document in plan + README; cross-link NEO-97; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | **adopted** |
|
||||
| **Flat `MaxPrototypeTargetHp` removal** | Delete constant; tests assert catalog values per instance. | **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** |
|
||||
|
||||
## Reconciliation (implementation)
|
||||
|
||||
- **`PrototypeNpcRegistry`** landed in `Game/Npc/`; **`PrototypeTargetRegistry`** and **`PrototypeCombatConstants`** removed.
|
||||
- **`InMemoryCombatEntityHealthStore`** injects **`INpcBehaviorDefinitionRegistry`** for per-instance catalog max HP.
|
||||
- Server tests + Bruno migrated to three NPC ids; melee 4× pulse defeat spine is AC verification.
|
||||
- **Client not updated** — [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) owns `prototype_target_constants.gd` migration.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# Code review — NEO-91 Prototype NPC instance registry + combat-target migration
|
||||
|
||||
**Date:** 2026-05-26
|
||||
**Scope:** Branch `NEO-91-npc-instance-registry-combat-target-migration` · commits `dadc35a`–`fa9acc8` vs `origin/main`
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-91 delivers **E5M2-05**: a frozen **`PrototypeNpcRegistry`** with three NPC combat instances (`prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite`), each bound to a behavior def and world anchor. **`PrototypeTargetRegistry`** and **`PrototypeCombatConstants`** are removed; **`InMemoryCombatEntityHealthStore`** now gates on the NPC registry and resolves per-instance **`maxHp`** via **`INpcBehaviorDefinitionRegistry`**. Targeting, cast, combat-targets GET, and the dev fixture reset all enumerate the three ids. Server tests and Bruno smokes are migrated; the melee 4× pulse defeat spine remains the AC verification path. Client constants are intentionally unchanged (breaking change documented for **NEO-97**). Implementation is coherent, thread-safe, and aligned with kickoff decisions. Risk is moderate only because of the breaking id change until NEO-97 lands — appropriately scoped and documented.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-91-implementation-plan.md`](../plans/NEO-91-implementation-plan.md) | **Matches** — kickoff table, three-instance bindings, per-archetype HP table, acceptance checklist, reconciliation section. |
|
||||
| [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-05** | **Matches** — AC checkboxes + landed note. |
|
||||
| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Matches** — NPC instance ids bullet added; **Status** / **Linear** summary rows cite **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — NEO-91 landed note with README link. **E5.M1** row NEO-80 blurb notes NEO-91 supersession of flat HP / dummy ids. |
|
||||
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) · **E5.M2 note** | **Matches** — NEO-91 / **`PrototypeNpcRegistry`** note appended. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — server-owned HP and target validity unchanged in authority model; id migration is server contract change only. |
|
||||
| [`server/README.md`](../../server/README.md) | **Matches** — three NPC ids, per-archetype max HP, breaking change + NEO-97 cross-link. |
|
||||
| Full-stack epic decomposition | **Matches** — server-only story; plan and README explicitly defer Godot to NEO-97; no false prototype-complete claim. |
|
||||
| Manual QA | **N/A** — plan documents skip (`Bruno` + automated tests only). |
|
||||
|
||||
Register/tracking: E5.M2 **In Progress** with NEO-91 in alignment table is correct; module register and E5.M2 Summary **Status** row should be extended (Suggestions).
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Add legacy-id targeting integration test**~~ — Plan test table calls for `TargetingApiTests`: select melee in range + **`prototype_target_alpha` → `unknown_target`**. Registry and health-store unit tests cover legacy rejection; add an HTTP-level test so tab-cycle / client migration regressions are caught at the API boundary. **Done.**
|
||||
|
||||
2. ~~**Update `module_dependency_register.md` E5.M2 note**~~ — Append **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration (three instance ids, catalog max HP) with links to [NEO-91 plan](../plans/NEO-91-implementation-plan.md) and [server README — Combat entity health (NEO-91)](../../server/README.md#combat-entity-health-neo-80-neo-91) (same pattern as NEO-90 note). **Done.**
|
||||
|
||||
3. ~~**E5.M2 Summary table rows**~~ — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend **Status** (line 10) and **Linear** (line 11) to cite **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) so they match the instance-id bullet and alignment table. **Done.**
|
||||
|
||||
4. ~~**Refresh stale Bruno `docs {}` blocks**~~ — Request bodies use new NPC ids, but several combat Bruno files still describe “alpha”/“beta” in prose (e.g. `combat-targets/Get combat targets after cast spine.bru`, `ability-cast/folder.bru`, `gig-progression/Get gig progression after defeat spine.bru`). Update docs to `prototype_npc_melee` / `prototype_npc_ranged` to avoid confusing manual runs. **Done.**
|
||||
|
||||
5. ~~**E5.M1 alignment row (NEO-80 blurb)**~~ — In `documentation_and_implementation_alignment.md`, the E5.M1 **NEO-80 landed** sentence still references **`PrototypeCombatConstants.MaxPrototypeTargetHp (100)`** and alpha/beta dummies. Add a forward note that NEO-91 superseded flat HP / dummy ids (or shorten the NEO-80 clause to “see NEO-91”). **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Nit: **`HostBehaviorRegistry_ShouldResolveEachInstanceBehaviorDef`**~~ — `Assert.True` / `Assert.Equal` run inside the `foreach` under `// Act`; move resolution to Act and assertions to `// Assert` (or use `[Theory]`) per [csharp-style AAA](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert). **Done.**
|
||||
|
||||
- ~~Nit: **`GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnMelee`**~~ — Asserts melee defeated and ranged unchanged; optional elite-at-200 assertion matches the plan’s expected snapshot table. **Done.**
|
||||
|
||||
- Nit: **`GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnMelee`** — `ReadFromJsonAsync` for the GET body runs in Act before `// Assert`; minor AAA blur (acceptable pattern elsewhere in repo).
|
||||
|
||||
- Nit: **`TryResolveMaxHp`** silently returns false when a registry binding points at a missing behavior def; **`HostBehaviorRegistry_ShouldResolveEachInstanceBehaviorDef`** mitigates at integration level. Optional fail-fast startup validation (mirror catalog load) if instance bindings become editable later.
|
||||
|
||||
- Nit: Bruno defeat spine does not assert elite unchanged (200/200); plan deferred elite pulse spine — consistent.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd server
|
||||
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||
--filter "FullyQualifiedName~Npc|FullyQualifiedName~CombatTarget|FullyQualifiedName~CombatEntityHealth|FullyQualifiedName~Targeting|FullyQualifiedName~AbilityCast"
|
||||
```
|
||||
|
||||
Confirmed during review: **96** filtered tests passed.
|
||||
|
||||
Postgres/Docker integration tests (11) fail in this environment without Docker — unrelated to NEO-91.
|
||||
|
||||
Optional: run Bruno `combat-targets/` folder against a dev server; **`Get combat targets after cast spine.bru`** is the AC defeat spine.
|
||||
|
||||
Before playable Godot verification: merge **NEO-97** client constant migration (`client/scripts/prototype_target_constants.gd`).
|
||||
|
|
@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Combat;
|
|||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -39,7 +40,7 @@ public sealed class AbilityCastApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -65,7 +66,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z)
|
||||
|
|
@ -97,7 +98,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
|
@ -108,7 +109,7 @@ public sealed class AbilityCastApiTests
|
|||
Assert.Null(body.ReasonCode);
|
||||
Assert.NotNull(body.CombatResolution);
|
||||
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, body.CombatResolution!.AbilityId);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.CombatResolution.TargetId);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.CombatResolution.TargetId);
|
||||
Assert.Equal(25, body.CombatResolution.DamageDealt);
|
||||
Assert.Equal(75, body.CombatResolution.TargetRemainingHp);
|
||||
Assert.False(body.CombatResolution.TargetDefeated);
|
||||
|
|
@ -131,7 +132,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = "prototype_target_alpha",
|
||||
TargetId = " Prototype_Npc_Melee ",
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
|
@ -214,7 +215,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
|
@ -241,7 +242,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
|
@ -270,7 +271,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
|
@ -485,7 +486,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -518,7 +519,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -552,7 +553,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
@ -691,7 +692,7 @@ public sealed class AbilityCastApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypeGuard,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
|
@ -28,7 +29,7 @@ public sealed class CooldownSnapshotApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
|
@ -81,7 +82,7 @@ public sealed class CooldownSnapshotApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using NeonSprawl.Server.Tests.Game.Npc;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
|
@ -9,17 +10,17 @@ namespace NeonSprawl.Server.Tests.Game.Combat;
|
|||
public sealed class CombatEntityHealthStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGet_OnFirstAccess_ShouldLazyInitAlphaToFullHp()
|
||||
public void TryGet_OnFirstAccess_ShouldLazyInitMeleeToCatalogMaxHp()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.MaxHp);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.TargetId);
|
||||
Assert.Equal(100, snapshot.MaxHp);
|
||||
Assert.Equal(100, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
|
||||
|
|
@ -27,9 +28,9 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryApplyDamage_ShouldReduceHp_WhenDamageIsApplied()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot);
|
||||
var applied = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(applied);
|
||||
Assert.Equal(75, snapshot.CurrentHp);
|
||||
|
|
@ -40,12 +41,12 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryApplyDamage_RepeatedUntilZero_ShouldMarkDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var first = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _);
|
||||
var second = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _);
|
||||
var third = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out _);
|
||||
var fourth = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var defeated);
|
||||
var first = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _);
|
||||
var second = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _);
|
||||
var third = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out _);
|
||||
var fourth = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var defeated);
|
||||
// Assert
|
||||
Assert.True(first);
|
||||
Assert.True(second);
|
||||
|
|
@ -59,10 +60,10 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryApplyDamage_OnDefeatedTarget_ShouldKeepHpAtZeroAndRemainDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||
// Act
|
||||
var overkill = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 25, out var snapshot);
|
||||
var overkill = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 25, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(overkill);
|
||||
Assert.Equal(0, snapshot.CurrentHp);
|
||||
|
|
@ -73,36 +74,51 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryResetToFull_AfterDefeat_ShouldRestoreFullHpAndClearDefeated()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||
// Act
|
||||
var reset = store.TryResetToFull(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
var reset = store.TryResetToFull(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(reset);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.Equal(100, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_OnBeta_ShouldLazyInitIndependentlyFromAlpha()
|
||||
public void TryGet_OnRanged_ShouldLazyInitIndependentlyFromMelee()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 40, out var alphaAfterDamage);
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 40, out var meleeAfterDamage);
|
||||
// Act
|
||||
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetBetaId, out var beta);
|
||||
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcRangedId, out var ranged);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp);
|
||||
Assert.False(beta.Defeated);
|
||||
Assert.Equal(60, alphaAfterDamage.CurrentHp);
|
||||
Assert.Equal(80, ranged.MaxHp);
|
||||
Assert.Equal(80, ranged.CurrentHp);
|
||||
Assert.False(ranged.Defeated);
|
||||
Assert.Equal(60, meleeAfterDamage.CurrentHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_OnElite_ShouldLazyInitToTwoHundredMaxHp()
|
||||
{
|
||||
// Arrange
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcEliteId, out var elite);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(200, elite.MaxHp);
|
||||
Assert.Equal(200, elite.CurrentHp);
|
||||
Assert.False(elite.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ShouldReturnFalse_WhenTargetIdIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet("not_a_prototype_target", out var snapshot);
|
||||
// Assert
|
||||
|
|
@ -114,7 +130,7 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryGet_ShouldReturnFalse_WhenTargetIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(" ", out var snapshot);
|
||||
// Assert
|
||||
|
|
@ -126,9 +142,9 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryApplyDamage_ShouldReturnFalse_WhenDamageIsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, -1, out var snapshot);
|
||||
var applied = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, -1, out var snapshot);
|
||||
// Assert
|
||||
Assert.False(applied);
|
||||
Assert.Equal(default, snapshot);
|
||||
|
|
@ -138,7 +154,7 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage("not_a_prototype_target", 25, out var snapshot);
|
||||
// Assert
|
||||
|
|
@ -150,7 +166,7 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryApplyDamage_ShouldReturnFalse_WhenTargetIdIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var applied = store.TryApplyDamage(" ", 25, out var snapshot);
|
||||
// Assert
|
||||
|
|
@ -162,20 +178,32 @@ public sealed class CombatEntityHealthStoreTests
|
|||
public void TryGet_ShouldNormalizeCaseVariantTargetIds()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet(" Prototype_Target_Alpha ", out var snapshot);
|
||||
var found = store.TryGet(" Prototype_Npc_Melee ", out var snapshot);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, snapshot.TargetId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.TargetId);
|
||||
Assert.Equal(100, snapshot.CurrentHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ShouldReturnFalse_ForLegacyAlphaDummyId()
|
||||
{
|
||||
// Arrange
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var found = store.TryGet("prototype_target_alpha", out var snapshot);
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Equal(default, snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResetToFull_ShouldReturnFalse_WhenTargetIdIsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var reset = store.TryResetToFull("not_a_prototype_target", out var snapshot);
|
||||
// Assert
|
||||
|
|
@ -192,10 +220,10 @@ public sealed class CombatEntityHealthStoreTests
|
|||
_ = await client.GetAsync("/health");
|
||||
// Act
|
||||
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||
var found = store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.Equal(100, snapshot.CurrentHp);
|
||||
Assert.False(snapshot.Defeated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using NeonSprawl.Server.Tests.Game.Npc;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
|
@ -47,11 +48,11 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
|
|
@ -67,12 +68,12 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
_ = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
}
|
||||
|
|
@ -80,7 +81,7 @@ public sealed class CombatOperationsTests
|
|||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
|
||||
|
|
@ -95,12 +96,12 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
_ = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
}
|
||||
|
|
@ -108,10 +109,10 @@ public sealed class CombatOperationsTests
|
|||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
|
|
@ -128,22 +129,22 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeGuard,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var snapshot);
|
||||
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
|
||||
Assert.Equal(100, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, snapshot.CurrentHp);
|
||||
Assert.Equal(100, snapshot.CurrentHp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -151,12 +152,12 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeGuard,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
|
|
@ -172,18 +173,18 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeDash,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
Assert.Null(result.ReasonCode);
|
||||
Assert.Equal(0, result.DamageDealt);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, result.TargetRemainingHp);
|
||||
Assert.Equal(100, result.TargetRemainingHp);
|
||||
Assert.False(result.TargetDefeated);
|
||||
}
|
||||
|
||||
|
|
@ -192,11 +193,11 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
"not_a_real_ability",
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
|
|
@ -211,7 +212,7 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
|
|
@ -230,11 +231,11 @@ public sealed class CombatOperationsTests
|
|||
{
|
||||
// Arrange
|
||||
var registry = CreatePrototypeRegistry();
|
||||
var store = new InMemoryCombatEntityHealthStore();
|
||||
var store = PrototypeNpcTestFixtures.CreateHealthStore();
|
||||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypeBurst,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
|
|
@ -256,7 +257,7 @@ public sealed class CombatOperationsTests
|
|||
// Act
|
||||
var result = CombatOperations.TryResolve(
|
||||
PrototypeAbilityRegistry.PrototypePulse,
|
||||
PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
registry,
|
||||
store);
|
||||
// Assert
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ using System.Net.Http.Json;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
|
@ -19,7 +20,7 @@ public sealed class CombatTargetFixtureApiTests
|
|||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||
_ = store.TryApplyDamage(PrototypeTargetRegistry.PrototypeTargetAlphaId, 100, out _);
|
||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
|
|
@ -34,9 +35,9 @@ public sealed class CombatTargetFixtureApiTests
|
|||
var body = await response.Content.ReadFromJsonAsync<CombatTargetFixtureResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.Applied);
|
||||
store.TryGet(PrototypeTargetRegistry.PrototypeTargetAlphaId, out var alpha);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp);
|
||||
Assert.False(alpha.Defeated);
|
||||
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee);
|
||||
Assert.Equal(100, melee.CurrentHp);
|
||||
Assert.False(melee.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -3,20 +3,16 @@ using System.Net;
|
|||
using System.Net.Http.Json;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using NeonSprawl.Server.Tests.Game.Npc;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Combat;
|
||||
|
||||
public class CombatTargetsWorldApiTests
|
||||
{
|
||||
/// <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)
|
||||
{
|
||||
var post = await client.PostAsJsonAsync(
|
||||
|
|
@ -29,14 +25,14 @@ public class CombatTargetsWorldApiTests
|
|||
post.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task LockPrototypeTargetAlphaAsync(HttpClient client)
|
||||
private static async Task LockPrototypeNpcMeleeAsync(HttpClient client)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
|
|
@ -50,11 +46,11 @@ public class CombatTargetsWorldApiTests
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task GetCombatTargets_ShouldReturnSchemaV1_WithBothTargetsAtFullHp()
|
||||
public async Task GetCombatTargets_ShouldReturnSchemaV1_WithThreeTargetsAtFullHp()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
|
|
@ -67,29 +63,34 @@ public class CombatTargetsWorldApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.Equal(CombatTargetsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||
Assert.NotNull(body.Targets);
|
||||
Assert.Equal(2, body.Targets.Count);
|
||||
Assert.Equal(3, body.Targets.Count);
|
||||
var ids = body.Targets.Select(static t => t.TargetId).ToList();
|
||||
Assert.Equal(PrototypeTargetsInIdOrder, ids);
|
||||
Assert.Equal(PrototypeNpcRegistryTests.InstancesInIdOrder, ids);
|
||||
|
||||
var alpha = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.MaxHp);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, alpha.CurrentHp);
|
||||
Assert.False(alpha.Defeated);
|
||||
var elite = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcEliteId);
|
||||
Assert.Equal(200, elite.MaxHp);
|
||||
Assert.Equal(200, elite.CurrentHp);
|
||||
Assert.False(elite.Defeated);
|
||||
|
||||
var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.MaxHp);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp);
|
||||
Assert.False(beta.Defeated);
|
||||
var melee = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||
Assert.Equal(100, melee.MaxHp);
|
||||
Assert.Equal(100, melee.CurrentHp);
|
||||
Assert.False(melee.Defeated);
|
||||
|
||||
var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId);
|
||||
Assert.Equal(80, ranged.MaxHp);
|
||||
Assert.Equal(80, ranged.CurrentHp);
|
||||
Assert.False(ranged.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnAlpha()
|
||||
public async Task GetCombatTargets_ShouldReflectCastDamage_AfterOnePulseOnMelee()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
await BindSlot0PulseAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
await LockPrototypeNpcMeleeAsync(client);
|
||||
var castResponse = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/ability-cast",
|
||||
PulseCastRequest());
|
||||
|
|
@ -107,23 +108,23 @@ public class CombatTargetsWorldApiTests
|
|||
Assert.Equal(75, castBody.CombatResolution!.TargetRemainingHp);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId);
|
||||
Assert.Equal(75, alpha.CurrentHp);
|
||||
Assert.False(alpha.Defeated);
|
||||
var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp);
|
||||
Assert.False(beta.Defeated);
|
||||
var melee = body!.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||
Assert.Equal(75, melee.CurrentHp);
|
||||
Assert.False(melee.Defeated);
|
||||
var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId);
|
||||
Assert.Equal(80, ranged.CurrentHp);
|
||||
Assert.False(ranged.Defeated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnAlpha()
|
||||
public async Task GetCombatTargets_ShouldShowDefeated_AfterFourPulsesOnMelee()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
Assert.NotNull(factory.FakeClock);
|
||||
await BindSlot0PulseAsync(client);
|
||||
await LockPrototypeTargetAlphaAsync(client);
|
||||
await LockPrototypeNpcMeleeAsync(client);
|
||||
var cast = PulseCastRequest();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
|
|
@ -142,11 +143,15 @@ public class CombatTargetsWorldApiTests
|
|||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<CombatTargetsListResponse>();
|
||||
Assert.NotNull(body);
|
||||
var alpha = body!.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetAlphaId);
|
||||
Assert.Equal(0, alpha.CurrentHp);
|
||||
Assert.True(alpha.Defeated);
|
||||
var beta = body.Targets.Single(t => t.TargetId == PrototypeTargetRegistry.PrototypeTargetBetaId);
|
||||
Assert.Equal(PrototypeCombatConstants.MaxPrototypeTargetHp, beta.CurrentHp);
|
||||
Assert.False(beta.Defeated);
|
||||
var melee = body!.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||
Assert.Equal(0, melee.CurrentHp);
|
||||
Assert.True(melee.Defeated);
|
||||
var ranged = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcRangedId);
|
||||
Assert.Equal(80, ranged.CurrentHp);
|
||||
Assert.False(ranged.Defeated);
|
||||
var elite = body.Targets.Single(t => t.TargetId == PrototypeNpcRegistry.PrototypeNpcEliteId);
|
||||
Assert.Equal(200, elite.CurrentHp);
|
||||
Assert.Equal(200, elite.MaxHp);
|
||||
Assert.False(elite.Defeated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
|
@ -27,7 +28,7 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
|
|||
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||
SlotIndex = 0,
|
||||
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
|
||||
// Act — defeat alpha through first host (four pulses)
|
||||
|
|
@ -45,7 +46,7 @@ public sealed class GigProgressionGrantPersistenceIntegrationTests(PostgresInteg
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
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>();
|
||||
var resolved = InstancesInIdOrder
|
||||
.Select(id =>
|
||||
{
|
||||
var found = PrototypeNpcRegistry.TryGet(id, out var entry);
|
||||
string? resolvedId = null;
|
||||
var resolvedDef = false;
|
||||
if (found && registry.TryGetDefinition(entry.BehaviorDefId, out var definition))
|
||||
{
|
||||
resolvedDef = true;
|
||||
resolvedId = definition.Id;
|
||||
}
|
||||
|
||||
return (
|
||||
InstanceId: id,
|
||||
Found: found,
|
||||
BehaviorDefId: found ? entry.BehaviorDefId : null,
|
||||
Resolved: resolvedDef,
|
||||
ResolvedId: resolvedId);
|
||||
})
|
||||
.ToList();
|
||||
// Assert
|
||||
Assert.Equal(InstancesInIdOrder.Length, resolved.Count);
|
||||
foreach (var row in resolved)
|
||||
{
|
||||
Assert.True(row.Found);
|
||||
Assert.True(row.Resolved);
|
||||
Assert.Equal(row.BehaviorDefId, row.ResolvedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ public class PlayerTargetStateReaderTests
|
|||
// Alpha anchor XZ (-3, -3), lockRadius 6 -> horizontal distance 6 from (3, -3) is on boundary (inclusive).
|
||||
var snap = new PositionSnapshot(3, 0, -3, 0);
|
||||
// Act
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.Ok, validity);
|
||||
}
|
||||
|
|
@ -50,23 +51,24 @@ public class PlayerTargetStateReaderTests
|
|||
const double eps = 1e-6;
|
||||
var snap = new PositionSnapshot(3 + eps, 0, -3, 0);
|
||||
// Act
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
|
||||
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.OutOfRange, validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets()
|
||||
public void ComputeValidity_AtOrigin_ShouldReturnOk_ForMeleeRangedAndElite()
|
||||
{
|
||||
// Arrange
|
||||
// NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can
|
||||
// flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m).
|
||||
// Melee (-3,-3) r=6, ranged (3,3) r=6, elite (0,0) r=6 overlap at origin for tab cycle.
|
||||
var snap = new PositionSnapshot(0, 0, 0, 0);
|
||||
// Act
|
||||
var alphaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
|
||||
var betaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap);
|
||||
var meleeValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcMeleeId, in snap);
|
||||
var rangedValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcRangedId, in snap);
|
||||
var eliteValidity = PlayerTargetStateReader.ComputeValidity(PrototypeNpcRegistry.PrototypeNpcEliteId, in snap);
|
||||
// Assert
|
||||
Assert.Equal(TargetValidity.Ok, alphaValidity);
|
||||
Assert.Equal(TargetValidity.Ok, betaValidity);
|
||||
Assert.Equal(TargetValidity.Ok, meleeValidity);
|
||||
Assert.Equal(TargetValidity.Ok, rangedValidity);
|
||||
Assert.Equal(TargetValidity.Ok, eliteValidity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
|||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ public class TargetingApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Null(body.ReasonCode);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.Ok, body.TargetState.Validity);
|
||||
Assert.Equal(1, body.TargetState.Sequence);
|
||||
}
|
||||
|
|
@ -86,6 +87,30 @@ public class TargetingApiTests
|
|||
Assert.Equal(TargetValidity.None, body.TargetState.Validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyLegacyAlphaDummyId_WithUnknownTarget()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/game/players/dev-local-1/target/select",
|
||||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = "prototype_target_alpha",
|
||||
});
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body!.SelectionApplied);
|
||||
Assert.Equal(TargetingApi.ReasonUnknownTarget, body.ReasonCode);
|
||||
Assert.Null(body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.None, body.TargetState.Validity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSelect_ShouldDenyOutOfRange_WhenBetaFarFromSpawn()
|
||||
{
|
||||
|
|
@ -100,7 +125,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
|
|
@ -128,7 +153,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 },
|
||||
});
|
||||
// Assert
|
||||
|
|
@ -138,7 +163,7 @@ public class TargetingApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Null(body.ReasonCode);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetBetaId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcRangedId, body.TargetState.LockedTargetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -156,7 +181,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
PositionHint = new PositionVector { X = 50.0, Y = 0.0, Z = 50.0 },
|
||||
});
|
||||
// Assert
|
||||
|
|
@ -184,7 +209,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetBetaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcRangedId,
|
||||
PositionHint = new PositionVector { X = 3.0, Y = 0.5, Z = 5.0 },
|
||||
});
|
||||
|
||||
|
|
@ -213,7 +238,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
var clear = await client.PostAsync(
|
||||
|
|
@ -247,7 +272,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
var move = new MoveCommandRequest
|
||||
|
|
@ -265,7 +290,7 @@ public class TargetingApiTests
|
|||
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body!.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.OutOfRange, body.Validity);
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +322,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
|
|
@ -318,7 +343,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = 999,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
// Assert
|
||||
|
||||
|
|
@ -360,7 +385,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = " PROTOTYPE_TARGET_ALPHA ",
|
||||
TargetId = " PROTOTYPE_NPC_MELEE ",
|
||||
});
|
||||
// Assert
|
||||
|
||||
|
|
@ -368,7 +393,7 @@ public class TargetingApiTests
|
|||
var body = await response.Content.ReadFromJsonAsync<TargetSelectResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.True(body!.SelectionApplied);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -382,7 +407,7 @@ public class TargetingApiTests
|
|||
var req = new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
};
|
||||
// Act
|
||||
var first = await client.PostAsJsonAsync("/game/players/dev-local-1/target/select", req);
|
||||
|
|
@ -407,7 +432,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
var move = new MoveCommandRequest
|
||||
|
|
@ -423,7 +448,7 @@ public class TargetingApiTests
|
|||
new TargetSelectRequest
|
||||
{
|
||||
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
|
||||
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
|
||||
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
|
|
@ -432,7 +457,7 @@ public class TargetingApiTests
|
|||
Assert.NotNull(body);
|
||||
Assert.False(body!.SelectionApplied);
|
||||
Assert.Equal(TargetingApi.ReasonOutOfRange, body.ReasonCode);
|
||||
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, body.TargetState.LockedTargetId);
|
||||
Assert.Equal(TargetValidity.OutOfRange, body.TargetState.Validity);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using NeonSprawl.Server.Diagnostics;
|
|||
using NeonSprawl.Server.Game.Combat;
|
||||
using NeonSprawl.Server.Game.Gigs;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
||||
|
|
@ -149,7 +150,7 @@ public static class AbilityCastApi
|
|||
}
|
||||
|
||||
var lookupKey = body.TargetId.Trim().ToLowerInvariant();
|
||||
if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry))
|
||||
if (!PrototypeNpcRegistry.TryGet(lookupKey, out var entry))
|
||||
{
|
||||
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
|
||||
return JsonAbilityCast(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ public static class CombatTargetFixtureApi
|
|||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
foreach (var id in PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder())
|
||||
foreach (var id in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||
{
|
||||
if (!healthStore.TryResetToFull(id, out _))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ public static class CombatTargetsWorldApi
|
|||
"/game/world/combat-targets",
|
||||
(ICombatEntityHealthStore healthStore) =>
|
||||
{
|
||||
var ids = PrototypeTargetRegistry.GetPrototypeTargetIdsInOrder();
|
||||
var ids = PrototypeNpcRegistry.GetInstanceIdsInOrder();
|
||||
var targets = new List<CombatTargetJson>(ids.Count);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Server-owned HP store for prototype combat dummies keyed by
|
||||
/// <see cref="Targeting.PrototypeTargetRegistry"/> target id (NEO-80).
|
||||
/// Server-owned HP store for prototype NPC combat instances keyed by
|
||||
/// <see cref="Npc.PrototypeNpcRegistry"/> instance id (NEO-80, NEO-91).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the current snapshot for a known prototype target. Lazy-initializes HP to
|
||||
/// <see cref="PrototypeCombatConstants.MaxPrototypeTargetHp"/> on first access.
|
||||
/// Reads the current snapshot for a known prototype NPC instance. Lazy-initializes HP to
|
||||
/// the bound behavior def <c>maxHp</c> on first access.
|
||||
/// </summary>
|
||||
bool TryGet(string? targetId, out CombatEntityHealthSnapshot snapshot);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
using System.Collections.Concurrent;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
using NeonSprawl.Server.Game.Npc;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Thread-safe in-memory HP for prototype combat dummies; empty at startup — lazy rows only (NEO-80).</summary>
|
||||
public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
|
||||
/// <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(INpcBehaviorDefinitionRegistry behaviorRegistry) : ICombatEntityHealthStore
|
||||
{
|
||||
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)
|
||||
{
|
||||
var key = NormalizeTargetId(targetId);
|
||||
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _))
|
||||
if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
|
|
@ -22,8 +22,8 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
|
|||
|
||||
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
EnsureRowInitialized(key);
|
||||
snapshot = CreateSnapshot(key, currentHpById[key]);
|
||||
EnsureRowInitialized(key, maxHp);
|
||||
snapshot = CreateSnapshot(key, maxHp, currentHpById[key]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,18 +38,18 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
|
|||
}
|
||||
|
||||
var key = NormalizeTargetId(targetId);
|
||||
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _))
|
||||
if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
EnsureRowInitialized(key);
|
||||
EnsureRowInitialized(key, maxHp);
|
||||
var current = currentHpById[key];
|
||||
var next = Math.Max(0, current - damage);
|
||||
currentHpById[key] = next;
|
||||
snapshot = CreateSnapshot(key, next);
|
||||
snapshot = CreateSnapshot(key, maxHp, next);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
|
|||
public bool TryResetToFull(string? targetId, out CombatEntityHealthSnapshot snapshot)
|
||||
{
|
||||
var key = NormalizeTargetId(targetId);
|
||||
if (key.Length == 0 || !PrototypeTargetRegistry.TryGet(key, out _))
|
||||
if (key.Length == 0 || !TryResolveMaxHp(key, out var maxHp))
|
||||
{
|
||||
snapshot = default;
|
||||
return false;
|
||||
|
|
@ -66,24 +66,41 @@ public sealed class InMemoryCombatEntityHealthStore : ICombatEntityHealthStore
|
|||
|
||||
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
currentHpById[key] = PrototypeCombatConstants.MaxPrototypeTargetHp;
|
||||
snapshot = CreateSnapshot(key, PrototypeCombatConstants.MaxPrototypeTargetHp);
|
||||
currentHpById[key] = maxHp;
|
||||
snapshot = CreateSnapshot(key, maxHp, maxHp);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRowInitialized(string normalizedId)
|
||||
private bool TryResolveMaxHp(string normalizedId, out int maxHp)
|
||||
{
|
||||
maxHp = 0;
|
||||
if (!PrototypeNpcRegistry.TryGet(normalizedId, out var entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
maxHp = definition.MaxHp;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnsureRowInitialized(string normalizedId, int maxHp)
|
||||
{
|
||||
if (!currentHpById.ContainsKey(normalizedId))
|
||||
{
|
||||
currentHpById[normalizedId] = PrototypeCombatConstants.MaxPrototypeTargetHp;
|
||||
currentHpById[normalizedId] = maxHp;
|
||||
}
|
||||
}
|
||||
|
||||
private static CombatEntityHealthSnapshot CreateSnapshot(string targetId, int currentHp) =>
|
||||
private static CombatEntityHealthSnapshot CreateSnapshot(string targetId, int maxHp, int currentHp) =>
|
||||
new(
|
||||
targetId,
|
||||
PrototypeCombatConstants.MaxPrototypeTargetHp,
|
||||
maxHp,
|
||||
currentHp,
|
||||
currentHp <= 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ public static class PlayerTargetStateReader
|
|||
return TargetValidity.None;
|
||||
}
|
||||
|
||||
if (!PrototypeTargetRegistry.TryGet(lockIdLowercase, out var entry))
|
||||
if (!PrototypeNpcRegistry.TryGet(lockIdLowercase, out var entry))
|
||||
{
|
||||
return TargetValidity.InvalidTarget;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NeonSprawl.Server.Game.Npc;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ public static class TargetingApi
|
|||
}
|
||||
|
||||
var lookupKey = trimmed.ToLowerInvariant();
|
||||
if (!PrototypeTargetRegistry.TryGet(lookupKey, out var entry))
|
||||
if (!PrototypeNpcRegistry.TryGet(lookupKey, out var entry))
|
||||
{
|
||||
var (lockUnknown, seqUnknown) = locks.GetLockState(id);
|
||||
var stateUnknown = PlayerTargetStateReader.Build(id, lockUnknown, seqUnknown, in snap);
|
||||
|
|
|
|||
|
|
@ -122,27 +122,29 @@ curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions"
|
|||
curl -sS -i "http://localhost:5253/game/world/ability-definitions"
|
||||
```
|
||||
|
||||
## Combat entity health (NEO-80)
|
||||
## Combat entity health (NEO-80, NEO-91)
|
||||
|
||||
Server-owned HP for prototype combat dummies lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeTargetRegistry`** ids (**`prototype_target_alpha`**, **`prototype_target_beta`**) only. Max HP is frozen at **`PrototypeCombatConstants.MaxPrototypeTargetHp` (100)** — four **`prototype_pulse`** casts (25 damage each) defeat one dummy.
|
||||
Server-owned HP for prototype NPC combat instances lives in **`Game/Combat/`** as **`ICombatEntityHealthStore`** + **`InMemoryCombatEntityHealthStore`**. Rows are keyed by **`PrototypeNpcRegistry`** instance ids (**`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`**) only. Max HP comes from each instance's bound **`NpcBehaviorDef.maxHp`** via **`INpcBehaviorDefinitionRegistry`** (melee **100**, ranged **80**, elite **200** — four **`prototype_pulse`** casts defeat melee).
|
||||
|
||||
| Policy | Behavior |
|
||||
|--------|----------|
|
||||
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id. |
|
||||
| **Init** | Lazy on first **`TryGet`** or **`TryApplyDamage`** for a known registry id; **`currentHp`** starts at catalog **`maxHp`**. |
|
||||
| **Damage** | **`TryApplyDamage`** floors HP at zero; **`defeated`** when **`currentHp == 0`**. Re-hit on defeated targets still applies at store layer (HP stays 0); structured deny is **NEO-81** (`CombatOperations`). |
|
||||
| **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives a dummy for tests/fixtures — not exposed over HTTP in Slice 1. |
|
||||
| **Reset** | **Server process restart** clears all rows (in-memory only, not persisted). **`TryResetToFull`** revives an instance for tests/fixtures — not exposed over HTTP in Slice 1. |
|
||||
| **HTTP read** | **`GET /game/world/combat-targets`** (NEO-83) — authoritative HP snapshot for client HUD; cast wiring is **NEO-82**. |
|
||||
|
||||
Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md); HTTP read: [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83).
|
||||
Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.md); instance registry migration: [NEO-91 implementation plan](../../docs/plans/NEO-91-implementation-plan.md); HTTP read: [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83).
|
||||
|
||||
## Combat targets snapshot (NEO-83)
|
||||
**Breaking change (NEO-91):** **`prototype_target_alpha`** / **`prototype_target_beta`** are removed. Godot markers and tab cycle must migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) (`client/scripts/prototype_target_constants.gd`).
|
||||
|
||||
**`GET /game/world/combat-targets`** returns a versioned JSON body (`schemaVersion` **1**, **`targets`**) backed by **`ICombatEntityHealthStore`** — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes **`targetId`**, **`maxHp`**, **`currentHp`**, and **`defeated`**. Both **`PrototypeTargetRegistry`** dummies are always returned in ascending **`targetId`** order; lazy-init on first read matches NEO-80. Plan: [NEO-83 implementation plan](../../docs/plans/NEO-83-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-targets/`.
|
||||
## Combat targets snapshot (NEO-83, NEO-91)
|
||||
|
||||
**`GET /game/world/combat-targets`** returns a versioned JSON body (`schemaVersion` **1**, **`targets`**) backed by **`ICombatEntityHealthStore`** — the same authoritative HP rows mutated by cast resolution (no client-side damage math). Each row includes **`targetId`**, **`maxHp`**, **`currentHp`**, and **`defeated`**. All three **`PrototypeNpcRegistry`** instances are always returned in ascending **`targetId`** order; lazy-init on first read matches NEO-80/NEO-91. Plan: [NEO-83 implementation plan](../../docs/plans/NEO-83-implementation-plan.md); [NEO-91 implementation plan](../../docs/plans/NEO-91-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/combat-targets/`.
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| **`targetId`** | Lowercase prototype registry id (`prototype_target_alpha`, `prototype_target_beta`). |
|
||||
| **`maxHp`** | Frozen prototype max (**100**). |
|
||||
| **`targetId`** | Lowercase NPC instance id (`prototype_npc_elite`, `prototype_npc_melee`, `prototype_npc_ranged`). |
|
||||
| **`maxHp`** | Catalog max from bound behavior def (200 / 100 / 80). |
|
||||
| **`currentHp`** | Authoritative HP after cast-applied damage; floored at zero. |
|
||||
| **`defeated`** | **`true`** when **`currentHp`** is **0**. |
|
||||
|
||||
|
|
@ -150,7 +152,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m
|
|||
curl -sS -i "http://localhost:5253/game/world/combat-targets"
|
||||
```
|
||||
|
||||
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeTargetRegistry`** dummies to full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
|
||||
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
|
||||
|
||||
## Combat engine (NEO-81)
|
||||
|
||||
|
|
@ -159,7 +161,7 @@ curl -sS -i "http://localhost:5253/game/world/combat-targets"
|
|||
| Reason code | When |
|
||||
|-------------|------|
|
||||
| **`unknown_ability`** | **`abilityId`** failed registry normalization (empty/garbage/off-list). |
|
||||
| **`unknown_target`** | **`targetId`** not in **`PrototypeTargetRegistry`** (health store gate). |
|
||||
| **`unknown_target`** | **`targetId`** not in **`PrototypeNpcRegistry`** (health store gate). |
|
||||
| **`target_defeated`** | Target HP is already **0** before resolve; no damage applied (includes zero-damage utility). |
|
||||
|
||||
**`CombatResult`** fields: **`success`**, **`reasonCode`** (`null` on success), **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**. On **`target_defeated`** deny, **`targetRemainingHp`** is **0** and **`targetDefeated`** on the envelope is **`false`** — use **`reasonCode`** for deny semantics.
|
||||
|
|
@ -542,7 +544,7 @@ Authoritative **combat target lock** (prototype): **`GET /game/players/{id}/targ
|
|||
|
||||
**Soft lock:** the server keeps the persisted **`lockedTargetId`** until clear or a successful swap. If the player moves out of stub range, **`validity`** becomes **`out_of_range`** on **`GET`** and on **`POST`** echoes until they move back in range, clear, or select another valid target (see [NEO-23 implementation plan](../../docs/plans/NEO-23-implementation-plan.md)).
|
||||
|
||||
Stub registry: `PrototypeTargetRegistry` in `NeonSprawl.Server/Game/Targeting/` — ids **`prototype_target_alpha`**, **`prototype_target_beta`** (ascending id order for future tab cycle).
|
||||
Stub registry: **`PrototypeNpcRegistry`** in `NeonSprawl.Server/Game/Npc/` — ids **`prototype_npc_elite`**, **`prototype_npc_melee`**, **`prototype_npc_ranged`** (ascending id order for tab cycle; client mirror in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
|
||||
|
||||
**`GET /game/players/{id}/target`**
|
||||
|
||||
|
|
@ -565,7 +567,7 @@ curl -s http://localhost:5253/game/players/dev-local-1/target
|
|||
```bash
|
||||
curl -s -X POST http://localhost:5253/game/players/dev-local-1/target/select \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"schemaVersion":1,"targetId":"prototype_target_alpha"}'
|
||||
-d '{"schemaVersion":1,"targetId":"prototype_npc_melee"}'
|
||||
```
|
||||
|
||||
**HTTP**
|
||||
|
|
@ -611,7 +613,7 @@ Current prototype allowlist: `prototype_pulse`, `prototype_guard`, `prototype_da
|
|||
|
||||
Prototype **cast intent** with **combat resolution** on accept (NEO-82). **NEO-28** adds server-side target lock + registry + range gates and documents `invalid_target` / `out_of_range` denies; the Godot client surfaces accept/deny on **`CastFeedbackLabel`**.
|
||||
|
||||
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeTargetRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**.
|
||||
- **`POST /game/players/{id}/ability-cast`** with `AbilityCastRequest` v1 (`schemaVersion`, `slotIndex` `0..7`, `abilityId`, `targetId`) validates the player exists, the slot is bound in persisted hotbar loadout, and `abilityId` matches that binding and the prototype allowlist. **NEO-28:** `targetId` must be **non-empty**, exist in **`PrototypeNpcRegistry`**, **match** the server's current combat lock (`IPlayerTargetLockStore`), and the lock must be **within horizontal reach** of the authoritative position snapshot (same XZ radius rule as targeting). **NEO-32:** rejects with `on_cooldown` when the slot is still cooling. **NEO-82:** after gates pass, invokes **`CombatOperations.TryResolve`**; on success commits per-ability **`cooldownSeconds`** from the ability catalog and returns **`AbilityCastResponse` v1** with nested **`combatResolution`**.
|
||||
|
||||
**Accept payload (`combatResolution`, present only when `accepted: true`):**
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue