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
VinPropane 2026-05-28 23:17:57 -04:00
parent 54429108ad
commit a68d8aa782
11 changed files with 537 additions and 12 deletions

View File

@ -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",
);
});
}

View File

@ -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);
});
}

View File

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

View File

@ -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/`.

View File

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

View File

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

View File

@ -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);
}
}

View File

@ -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; }
}

View File

@ -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,
};
}
}

View File

@ -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();

View File

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