NEO-94: Add GET /game/world/npc-runtime-snapshot with lazy AdvanceAll.
Expose versioned NPC runtime rows and nested activeTelegraph DTOs for client poll; wire route, AAA integration tests, Bruno smoke, and docs.pull/132/head
parent
54429108ad
commit
a68d8aa782
|
|
@ -0,0 +1,67 @@
|
||||||
|
meta {
|
||||||
|
name: Get npc runtime snapshot
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const { resetPrototypeCombatTargets } = require("../scripts/combat-targets-reset-helper.js");
|
||||||
|
await resetPrototypeCombatTargets(bru);
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/game/world/npc-runtime-snapshot
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("returns 200 JSON with schema v1 and npcInstances array", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
expect(res.getHeader("content-type")).to.contain("application/json");
|
||||||
|
const body = res.getBody();
|
||||||
|
expect(body.schemaVersion).to.equal(1);
|
||||||
|
expect(body.serverTimeUtc).to.be.a("string");
|
||||||
|
expect(body.npcInstances).to.be.an("array");
|
||||||
|
expect(body.npcInstances.length).to.equal(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("npcInstances are ascending by npcInstanceId (ordinal)", function () {
|
||||||
|
const body = res.getBody();
|
||||||
|
const ids = body.npcInstances.map((x) => x.npcInstanceId);
|
||||||
|
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||||
|
expect(ids).to.eql(sorted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prototype NPC registry instances match id order", function () {
|
||||||
|
const body = res.getBody();
|
||||||
|
const ids = body.npcInstances.map((x) => x.npcInstanceId);
|
||||||
|
expect(ids).to.eql([
|
||||||
|
"prototype_npc_elite",
|
||||||
|
"prototype_npc_melee",
|
||||||
|
"prototype_npc_ranged",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all instances start idle with null holder and telegraph on fresh server", function () {
|
||||||
|
const body = res.getBody();
|
||||||
|
for (const row of body.npcInstances) {
|
||||||
|
expect(row.state).to.equal("idle");
|
||||||
|
expect(row.aggroHolderPlayerId).to.equal(null);
|
||||||
|
expect(row.activeTelegraph).to.equal(null);
|
||||||
|
expect(row.behaviorDefId).to.be.a("string");
|
||||||
|
}
|
||||||
|
const byId = Object.fromEntries(
|
||||||
|
body.npcInstances.map((x) => [x.npcInstanceId, x]),
|
||||||
|
);
|
||||||
|
expect(byId.prototype_npc_melee.behaviorDefId).to.equal(
|
||||||
|
"prototype_melee_pressure",
|
||||||
|
);
|
||||||
|
expect(byId.prototype_npc_ranged.behaviorDefId).to.equal(
|
||||||
|
"prototype_ranged_control",
|
||||||
|
);
|
||||||
|
expect(byId.prototype_npc_elite.behaviorDefId).to.equal(
|
||||||
|
"prototype_elite_mini_boss",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
meta {
|
||||||
|
name: Get snapshot after cast telegraph
|
||||||
|
type: http
|
||||||
|
seq: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
E5M2-08 AC smoke: lock prototype_npc_melee, damaging cast, wait attackCooldownSeconds (3.0s), GET snapshot.
|
||||||
|
Integration tests own precise timing via FakeClock; Bruno uses sleep(3100) against dev server real clock.
|
||||||
|
}
|
||||||
|
|
||||||
|
script:pre-request {
|
||||||
|
const axios = require("axios");
|
||||||
|
const { resetPrototypeCombatTargets } = require("../scripts/combat-targets-reset-helper.js");
|
||||||
|
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
||||||
|
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
|
||||||
|
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetPrototypeCombatTargets(bru);
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/move`,
|
||||||
|
{ schemaVersion: 1, target: { x: -5, y: 0.9, z: -5 } },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
|
||||||
|
},
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/target/select`,
|
||||||
|
{ schemaVersion: 1, targetId: "prototype_npc_melee" },
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
const castResponse = await axios.post(
|
||||||
|
`${baseUrl}/game/players/${playerId}/ability-cast`,
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
slotIndex: 0,
|
||||||
|
abilityId: "prototype_pulse",
|
||||||
|
targetId: "prototype_npc_melee",
|
||||||
|
},
|
||||||
|
jsonHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
const castBody = castResponse.data;
|
||||||
|
if (!castBody?.accepted || !castBody?.combatResolution) {
|
||||||
|
throw new Error(`neo94 cast failed: ${JSON.stringify(castBody)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.get(`${baseUrl}/game/world/npc-runtime-snapshot`);
|
||||||
|
|
||||||
|
await sleep(3100);
|
||||||
|
|
||||||
|
bru.setVar("neo94CastBody", JSON.stringify(castBody));
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/game/world/npc-runtime-snapshot
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("cast body shows accepted damaging pulse on melee", function () {
|
||||||
|
const castBody = JSON.parse(bru.getVar("neo94CastBody"));
|
||||||
|
expect(castBody.accepted).to.equal(true);
|
||||||
|
expect(castBody.combatResolution.targetRemainingHp).to.equal(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("melee shows telegraph_windup with activeTelegraph after cooldown", function () {
|
||||||
|
const body = res.getBody();
|
||||||
|
const melee = body.npcInstances.find(
|
||||||
|
(x) => x.npcInstanceId === "prototype_npc_melee",
|
||||||
|
);
|
||||||
|
expect(melee).to.be.an("object");
|
||||||
|
expect(melee.state).to.equal("telegraph_windup");
|
||||||
|
expect(melee.aggroHolderPlayerId).to.equal("dev-local-1");
|
||||||
|
expect(melee.activeTelegraph).to.be.an("object");
|
||||||
|
expect(melee.activeTelegraph.archetypeKind).to.equal("melee_pressure");
|
||||||
|
expect(melee.activeTelegraph.windupRemainingSeconds).to.be.above(0);
|
||||||
|
expect(melee.activeTelegraph.npcInstanceId).to.equal("prototype_npc_melee");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-target NPCs remain idle", function () {
|
||||||
|
const body = res.getBody();
|
||||||
|
const ranged = body.npcInstances.find(
|
||||||
|
(x) => x.npcInstanceId === "prototype_npc_ranged",
|
||||||
|
);
|
||||||
|
const elite = body.npcInstances.find(
|
||||||
|
(x) => x.npcInstanceId === "prototype_npc_elite",
|
||||||
|
);
|
||||||
|
expect(ranged.state).to.equal("idle");
|
||||||
|
expect(ranged.activeTelegraph).to.equal(null);
|
||||||
|
expect(elite.state).to.equal("idle");
|
||||||
|
expect(elite.activeTelegraph).to.equal(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
meta {
|
||||||
|
name: npc-runtime-snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-94: GET /game/world/npc-runtime-snapshot — lazy AdvanceAll + runtime rows + active telegraphs.
|
||||||
|
Pre-request scripts call POST /game/__dev/combat-targets-fixture so CI survives ability-cast folder pollution.
|
||||||
|
Telegraph smoke (seq 2) uses sleep(3100) against real dev server clock; integration tests use FakeClock.
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
| **Module ID** | E5.M2 |
|
| **Module ID** | E5.M2 |
|
||||||
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
|
||||||
| **Stage target** | Prototype |
|
| **Stage target** | Prototype |
|
||||||
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed → **E5M2-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-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed · **E5M2-08** [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) runtime snapshot GET landed → **E5M2-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) |
|
| **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
|
## Purpose
|
||||||
|
|
@ -83,9 +83,11 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
|
||||||
|
|
||||||
**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 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).
|
||||||
|
|
||||||
**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md).
|
**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection: **`aggroHolderPlayerId`** on **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md).
|
||||||
|
|
||||||
**NPC runtime state machine (NEO-93):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** in `server/NeonSprawl.Server/Game/Npc/` — lazy tick advance for **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**; holder sync from **`IThreatStateStore`**; HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-93 implementation plan](../../plans/NEO-93-implementation-plan.md).
|
**NPC runtime state machine (NEO-93):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** in `server/NeonSprawl.Server/Game/Npc/` — lazy tick advance for **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**; holder sync from **`IThreatStateStore`**. HTTP projection: **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)). Plan: [NEO-93 implementation plan](../../plans/NEO-93-implementation-plan.md).
|
||||||
|
|
||||||
|
**NPC runtime snapshot HTTP (NEO-94):** **`GET /game/world/npc-runtime-snapshot`** — versioned read-only projection (`schemaVersion` **1**, **`npcInstances`**, optional nested **`activeTelegraph`**) with lazy **`AdvanceAll`** on poll. Plan: [NEO-94 implementation plan](../../plans/NEO-94-implementation-plan.md); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
|
||||||
|
|
||||||
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,9 +268,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
|
||||||
|
|
||||||
**Acceptance criteria**
|
**Acceptance criteria**
|
||||||
|
|
||||||
- [ ] Snapshot reflects state transition within one poll generation after aggro.
|
- [x] Snapshot reflects state transition within one poll generation after aggro.
|
||||||
- [ ] Active telegraph row appears during windup with decreasing remaining time.
|
- [x] Active telegraph row appears during windup with decreasing remaining time.
|
||||||
- [ ] Bruno smoke: lock NPC → damage → poll until telegraph row present.
|
- [x] Bruno smoke: lock NPC → damage → poll until telegraph row present.
|
||||||
|
|
||||||
|
**Landed ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)):** **`GET /game/world/npc-runtime-snapshot`** — **`NpcRuntimeSnapshotWorldApi`** + DTOs, lazy **`AdvanceAll`** on poll, nested **`activeTelegraph`**; plan [NEO-94-implementation-plan.md](NEO-94-implementation-plan.md).
|
||||||
|
|
||||||
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph label + NPC state HUD driven by this GET.
|
**Client counterpart:** [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph label + NPC state HUD driven by this GET.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,9 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
- [ ] Snapshot reflects state transition within one poll generation after aggro (cast acquire → next GET shows **`aggro`** on locked NPC).
|
- [x] Snapshot reflects state transition within one poll generation after aggro (cast acquire → next GET shows **`aggro`** on locked NPC).
|
||||||
- [ ] Active telegraph row appears during windup with decreasing **`windupRemainingSeconds`** across polls.
|
- [x] Active telegraph row appears during windup with decreasing **`windupRemainingSeconds`** across polls.
|
||||||
- [ ] Bruno smoke: lock NPC → damage → poll until **`activeTelegraph`** present on melee row.
|
- [x] Bruno smoke: lock NPC → damage → poll until **`activeTelegraph`** present on melee row.
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
using System.Linq;
|
||||||
|
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;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||||
|
|
||||||
|
public sealed class NpcRuntimeSnapshotWorldApiTests
|
||||||
|
{
|
||||||
|
private static async Task BindSlot0PulseAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
var post = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/hotbar-loadout",
|
||||||
|
new HotbarLoadoutUpdateRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
|
||||||
|
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
|
||||||
|
});
|
||||||
|
post.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
});
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbilityCastRequest PulseCastRequest() =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
|
||||||
|
SlotIndex = 0,
|
||||||
|
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
|
||||||
|
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNpcRuntimeSnapshot_ShouldReturnSchemaV1_WithThreeIdleInstances()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
|
||||||
|
Assert.NotNull(body);
|
||||||
|
Assert.Equal(NpcRuntimeSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
|
||||||
|
Assert.NotEqual(default, body.ServerTimeUtc);
|
||||||
|
Assert.NotNull(body.NpcInstances);
|
||||||
|
Assert.Equal(3, body.NpcInstances.Count);
|
||||||
|
var ids = body.NpcInstances.Select(static row => row.NpcInstanceId).ToList();
|
||||||
|
Assert.Equal(PrototypeNpcRegistryTests.InstancesInIdOrder, ids);
|
||||||
|
|
||||||
|
foreach (var row in body.NpcInstances)
|
||||||
|
{
|
||||||
|
Assert.Equal("idle", row.State);
|
||||||
|
Assert.Null(row.AggroHolderPlayerId);
|
||||||
|
Assert.Null(row.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
var melee = body.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Assert.Equal(PrototypeNpcBehaviorRegistry.PrototypeMeleePressure, melee.BehaviorDefId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNpcRuntimeSnapshot_ShouldShowAggro_OnMeleeAfterDamagingCast()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
await BindSlot0PulseAsync(client);
|
||||||
|
await LockPrototypeNpcMeleeAsync(client);
|
||||||
|
var castResponse = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/ability-cast",
|
||||||
|
PulseCastRequest());
|
||||||
|
castResponse.EnsureSuccessStatusCode();
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.NotNull(body);
|
||||||
|
var melee = body!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Assert.Equal("aggro", melee.State);
|
||||||
|
Assert.Equal("dev-local-1", melee.AggroHolderPlayerId);
|
||||||
|
Assert.Null(melee.ActiveTelegraph);
|
||||||
|
|
||||||
|
var ranged = body.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcRangedId);
|
||||||
|
Assert.Equal("idle", ranged.State);
|
||||||
|
Assert.Null(ranged.AggroHolderPlayerId);
|
||||||
|
Assert.Null(ranged.ActiveTelegraph);
|
||||||
|
|
||||||
|
var elite = body.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcEliteId);
|
||||||
|
Assert.Equal("idle", elite.State);
|
||||||
|
Assert.Null(elite.AggroHolderPlayerId);
|
||||||
|
Assert.Null(elite.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNpcRuntimeSnapshot_ShouldShowTelegraph_AfterAttackCooldownElapses()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
Assert.NotNull(factory.FakeClock);
|
||||||
|
await BindSlot0PulseAsync(client);
|
||||||
|
await LockPrototypeNpcMeleeAsync(client);
|
||||||
|
var castResponse = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/ability-cast",
|
||||||
|
PulseCastRequest());
|
||||||
|
castResponse.EnsureSuccessStatusCode();
|
||||||
|
var initResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
initResponse.EnsureSuccessStatusCode();
|
||||||
|
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3));
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.NotNull(body);
|
||||||
|
var melee = body!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Assert.Equal("telegraph_windup", melee.State);
|
||||||
|
Assert.NotNull(melee.ActiveTelegraph);
|
||||||
|
Assert.Equal("melee_pressure", melee.ActiveTelegraph!.ArchetypeKind);
|
||||||
|
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, melee.ActiveTelegraph.NpcInstanceId);
|
||||||
|
Assert.InRange(melee.ActiveTelegraph.WindupRemainingSeconds, 1.49, 1.51);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetNpcRuntimeSnapshot_ShouldDecreaseWindupRemaining_AcrossPolls()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
Assert.NotNull(factory.FakeClock);
|
||||||
|
await BindSlot0PulseAsync(client);
|
||||||
|
await LockPrototypeNpcMeleeAsync(client);
|
||||||
|
var castResponse = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/dev-local-1/ability-cast",
|
||||||
|
PulseCastRequest());
|
||||||
|
castResponse.EnsureSuccessStatusCode();
|
||||||
|
var initResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
initResponse.EnsureSuccessStatusCode();
|
||||||
|
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3));
|
||||||
|
var firstResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
var firstBody = await firstResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
|
||||||
|
factory.FakeClock.Advance(TimeSpan.FromSeconds(1));
|
||||||
|
// Act
|
||||||
|
var secondResponse = await client.GetAsync("/game/world/npc-runtime-snapshot");
|
||||||
|
var secondBody = await secondResponse.Content.ReadFromJsonAsync<NpcRuntimeSnapshotResponse>();
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(firstBody);
|
||||||
|
Assert.NotNull(secondBody);
|
||||||
|
var firstMelee = firstBody!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
var secondMelee = secondBody!.NpcInstances.Single(row => row.NpcInstanceId == PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Assert.NotNull(firstMelee.ActiveTelegraph);
|
||||||
|
Assert.NotNull(secondMelee.ActiveTelegraph);
|
||||||
|
Assert.True(secondMelee.ActiveTelegraph!.WindupRemainingSeconds < firstMelee.ActiveTelegraph!.WindupRemainingSeconds);
|
||||||
|
Assert.InRange(secondMelee.ActiveTelegraph.WindupRemainingSeconds, 0.49, 0.51);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>JSON body for <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary>
|
||||||
|
public sealed class NpcRuntimeSnapshotResponse
|
||||||
|
{
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
[JsonPropertyName("schemaVersion")]
|
||||||
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
|
||||||
|
[JsonPropertyName("serverTimeUtc")]
|
||||||
|
public DateTimeOffset ServerTimeUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Prototype NPC runtime rows ordered by ascending <c>npcInstanceId</c>.</summary>
|
||||||
|
[JsonPropertyName("npcInstances")]
|
||||||
|
public required IReadOnlyList<NpcInstanceRuntimeJson> NpcInstances { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One row in the NPC runtime snapshot projection.</summary>
|
||||||
|
public sealed class NpcInstanceRuntimeJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("npcInstanceId")]
|
||||||
|
public required string NpcInstanceId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("behaviorDefId")]
|
||||||
|
public required string BehaviorDefId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public required string State { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("aggroHolderPlayerId")]
|
||||||
|
public string? AggroHolderPlayerId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("activeTelegraph")]
|
||||||
|
public ActiveTelegraphJson? ActiveTelegraph { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Active telegraph nested on an NPC row during <c>telegraph_windup</c>.</summary>
|
||||||
|
public sealed class ActiveTelegraphJson
|
||||||
|
{
|
||||||
|
[JsonPropertyName("telegraphId")]
|
||||||
|
public required string TelegraphId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("npcInstanceId")]
|
||||||
|
public required string NpcInstanceId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("windupRemainingSeconds")]
|
||||||
|
public required double WindupRemainingSeconds { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("archetypeKind")]
|
||||||
|
public required string ArchetypeKind { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>Maps <c>GET /game/world/npc-runtime-snapshot</c> (NEO-94).</summary>
|
||||||
|
public static class NpcRuntimeSnapshotWorldApi
|
||||||
|
{
|
||||||
|
public static WebApplication MapNpcRuntimeSnapshotWorldApi(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapGet(
|
||||||
|
"/game/world/npc-runtime-snapshot",
|
||||||
|
(
|
||||||
|
TimeProvider clock,
|
||||||
|
INpcRuntimeStateStore runtimeStore,
|
||||||
|
IThreatStateStore threatStore,
|
||||||
|
INpcBehaviorDefinitionRegistry behaviorRegistry) =>
|
||||||
|
{
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry);
|
||||||
|
return Results.Json(BuildSnapshot(now, runtimeStore, threatStore, behaviorRegistry));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static NpcRuntimeSnapshotResponse BuildSnapshot(
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
INpcRuntimeStateStore runtimeStore,
|
||||||
|
IThreatStateStore threatStore,
|
||||||
|
INpcBehaviorDefinitionRegistry behaviorRegistry)
|
||||||
|
{
|
||||||
|
var ids = PrototypeNpcRegistry.GetInstanceIdsInOrder();
|
||||||
|
var instances = new List<NpcInstanceRuntimeJson>(ids.Count);
|
||||||
|
foreach (var npcInstanceId in ids)
|
||||||
|
{
|
||||||
|
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Prototype NPC instance '{npcInstanceId}' is missing from {nameof(PrototypeNpcRegistry)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Behavior definition '{entry.BehaviorDefId}' for NPC '{npcInstanceId}' is missing from {nameof(INpcBehaviorDefinitionRegistry)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtimeStore.TryGet(npcInstanceId, out var runtime))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Prototype NPC runtime row '{npcInstanceId}' is registered but missing from {nameof(INpcRuntimeStateStore)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? aggroHolderPlayerId = null;
|
||||||
|
if (threatStore.TryGet(npcInstanceId, out var threat))
|
||||||
|
{
|
||||||
|
aggroHolderPlayerId = threat.AggroHolderPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActiveTelegraphJson? activeTelegraph = null;
|
||||||
|
if (runtime.BehaviorState == NpcBehaviorState.TelegraphWindup &&
|
||||||
|
runtime.ActiveTelegraph is { } telegraph)
|
||||||
|
{
|
||||||
|
var elapsedSeconds = (nowUtc - telegraph.WindupStartedUtc).TotalSeconds;
|
||||||
|
var remainingSeconds = Math.Max(0, behavior.TelegraphWindupSeconds - elapsedSeconds);
|
||||||
|
activeTelegraph = new ActiveTelegraphJson
|
||||||
|
{
|
||||||
|
TelegraphId = telegraph.TelegraphId,
|
||||||
|
NpcInstanceId = npcInstanceId,
|
||||||
|
WindupRemainingSeconds = remainingSeconds,
|
||||||
|
ArchetypeKind = behavior.ArchetypeKind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
instances.Add(
|
||||||
|
new NpcInstanceRuntimeJson
|
||||||
|
{
|
||||||
|
NpcInstanceId = npcInstanceId,
|
||||||
|
BehaviorDefId = entry.BehaviorDefId,
|
||||||
|
State = NpcBehaviorStateWire.ToWireName(runtime.BehaviorState),
|
||||||
|
AggroHolderPlayerId = aggroHolderPlayerId,
|
||||||
|
ActiveTelegraph = activeTelegraph,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NpcRuntimeSnapshotResponse
|
||||||
|
{
|
||||||
|
ServerTimeUtc = nowUtc,
|
||||||
|
NpcInstances = instances,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,7 @@ app.MapRecipeDefinitionsWorldApi();
|
||||||
app.MapAbilityDefinitionsWorldApi();
|
app.MapAbilityDefinitionsWorldApi();
|
||||||
app.MapNpcBehaviorDefinitionsWorldApi();
|
app.MapNpcBehaviorDefinitionsWorldApi();
|
||||||
app.MapCombatTargetsWorldApi();
|
app.MapCombatTargetsWorldApi();
|
||||||
|
app.MapNpcRuntimeSnapshotWorldApi();
|
||||||
app.MapResourceNodeDefinitionsWorldApi();
|
app.MapResourceNodeDefinitionsWorldApi();
|
||||||
app.MapPlayerInventoryApi();
|
app.MapPlayerInventoryApi();
|
||||||
app.MapPlayerCraftApi();
|
app.MapPlayerCraftApi();
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In
|
||||||
| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. |
|
| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. |
|
||||||
| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. |
|
| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. |
|
||||||
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
|
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
|
||||||
| **HTTP read** | Not exposed yet — **`GET /game/world/npc-runtime-snapshot`** lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). |
|
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — **`aggroHolderPlayerId`** per row ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
|
||||||
|
|
||||||
Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md).
|
Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md).
|
||||||
|
|
||||||
|
|
@ -182,12 +182,30 @@ Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** +
|
||||||
| Rule | Behavior |
|
| Rule | Behavior |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| **Holder sync** | Empty holder → **`idle`**; holder while **`idle`** → **`aggro`**. No proximity auto-aggro (first-hit holder from NEO-92 only). |
|
| **Holder sync** | Empty holder → **`idle`**; holder while **`idle`** → **`aggro`**. No proximity auto-aggro (first-hit holder from NEO-92 only). |
|
||||||
| **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) wires this to **`GET /game/world/npc-runtime-snapshot`**. |
|
| **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; invoked from **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
|
||||||
| **Fixture reset** | Dev combat-target fixture clears runtime rows alongside HP + threat. |
|
| **Fixture reset** | Dev combat-target fixture clears runtime rows alongside HP + threat. |
|
||||||
| **HTTP read** | Not exposed yet — snapshot GET lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). |
|
| **HTTP read** | **`GET /game/world/npc-runtime-snapshot`** — per-NPC **`state`**, optional nested **`activeTelegraph`** ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94)). |
|
||||||
|
|
||||||
Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.md).
|
Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.md).
|
||||||
|
|
||||||
|
## NPC runtime snapshot (NEO-94)
|
||||||
|
|
||||||
|
**`GET /game/world/npc-runtime-snapshot`** returns a versioned JSON body (`schemaVersion` **1**, **`serverTimeUtc`**, **`npcInstances`**) backed by **`INpcRuntimeStateStore`** + **`IThreatStateStore`** after **`NpcRuntimeOperations.AdvanceAll`** (lazy tick, **5.0 s** delta cap). Each row includes **`npcInstanceId`**, **`behaviorDefId`**, **`state`** (stable snake_case), **`aggroHolderPlayerId`**, and optional nested **`activeTelegraph`** during **`telegraph_windup`**. All three **`PrototypeNpcRegistry`** instances are always returned in ascending **`npcInstanceId`** order. Plan: [NEO-94 implementation plan](../../docs/plans/NEO-94-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-runtime-snapshot/`.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| **`npcInstanceId`** | Lowercase prototype NPC instance id. |
|
||||||
|
| **`behaviorDefId`** | Frozen behavior catalog id bound to the instance. |
|
||||||
|
| **`state`** | **`idle`**, **`aggro`**, **`telegraph_windup`**, **`attack_execute`**, or **`recover`**. |
|
||||||
|
| **`aggroHolderPlayerId`** | Lowercase player id from **`IThreatStateStore`**, or **`null`**. |
|
||||||
|
| **`activeTelegraph`** | Non-null during **`telegraph_windup`**: **`telegraphId`**, **`npcInstanceId`**, **`windupRemainingSeconds`**, **`archetypeKind`**. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -i "http://localhost:5253/game/world/npc-runtime-snapshot"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Poll pattern:** First GET after aggro acquire initializes runtime rows; subsequent GETs after **`attackCooldownSeconds`** elapse show **`telegraph_windup`** with decreasing **`windupRemainingSeconds`**. Cast does not advance NPC runtime — only this GET (and future explicit **`AdvanceAll`** callers).
|
||||||
|
|
||||||
## Combat engine (NEO-81)
|
## Combat engine (NEO-81)
|
||||||
|
|
||||||
**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept.
|
**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue