NEO-90: add GET /game/world/npc-behavior-definitions projection.

Expose read-only npcBehaviors envelope backed by INpcBehaviorDefinitionRegistry,
with integration tests, Bruno smoke, and doc reconciliation.
pull/127/head
VinPropane 2026-05-25 17:49:01 -04:00
parent c757b7a385
commit d3e765fb8f
11 changed files with 244 additions and 9 deletions

View File

@ -0,0 +1,67 @@
meta {
name: GET npc behavior definitions
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/world/npc-behavior-definitions
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and npcBehaviors 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.npcBehaviors).to.be.an("array");
expect(body.npcBehaviors.length).to.equal(3);
});
test("npcBehaviors are ascending by id (ordinal)", function () {
const body = res.getBody();
const ids = body.npcBehaviors.map((x) => x.id);
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
expect(ids).to.eql(sorted);
});
test("frozen prototype three matches registry id order", function () {
const body = res.getBody();
const ids = body.npcBehaviors.map((x) => x.id);
expect(ids).to.eql([
"prototype_elite_mini_boss",
"prototype_melee_pressure",
"prototype_ranged_control",
]);
});
test("prototype_melee_pressure row matches catalog", function () {
const body = res.getBody();
const row = body.npcBehaviors.find((x) => x.id === "prototype_melee_pressure");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Melee Pressure");
expect(row.archetypeKind).to.equal("melee_pressure");
expect(row.maxHp).to.equal(100);
expect(row.aggroRadius).to.equal(8);
expect(row.leashRadius).to.equal(16);
expect(row.telegraphWindupSeconds).to.equal(1.5);
expect(row.attackDamage).to.equal(15);
expect(row.attackCooldownSeconds).to.equal(3);
});
test("prototype_elite_mini_boss row matches catalog", function () {
const body = res.getBody();
const row = body.npcBehaviors.find((x) => x.id === "prototype_elite_mini_boss");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Elite Mini-Boss");
expect(row.archetypeKind).to.equal("elite_mini_boss");
expect(row.maxHp).to.equal(200);
expect(row.aggroRadius).to.equal(8);
expect(row.leashRadius).to.equal(18);
expect(row.telegraphWindupSeconds).to.equal(2.5);
expect(row.attackDamage).to.equal(25);
expect(row.attackCooldownSeconds).to.equal(5);
});
}

View File

@ -0,0 +1,3 @@
meta {
name: npc-behavior-definitions
}

View File

@ -7,7 +7,7 @@
| **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-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-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-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
## Purpose
@ -42,7 +42,7 @@ Enemy archetype behavior loops, aggro logic, and telegraph scheduling on top of
## Related implementation slices
Epic 5 **Slice 2 — NPC archetypes and telegraphs**: three archetypes (melee pressure, ranged control, elite mini-boss); telemetry `telegraph_fired`, `npc_state_transition`.
Epic 5 **Slice 2 — NPC archetypes and telegraphs**: three archetypes (melee pressure, ranged control, elite mini-boss); telemetry `telegraph_fired`, `npc_state_transition`. **HTTP read model (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/`; Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
## Linear backlog (decomposed)
@ -79,7 +79,9 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**Server load (NEO-88):** On host startup, `server/NeonSprawl.Server/Game/Npc/` loads `content/npc-behaviors/*_npc_behaviors.json` with the same validation gates as CI and **refuses to listen** when the catalog is invalid. Config and discovery: [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). Plan: [NEO-88 implementation plan](../../plans/NEO-88-implementation-plan.md).
**NPC behavior definition registry (NEO-89):** **`INpcBehaviorDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Npc/` wraps the startup-loaded catalog for read-only lookup by stable behavior `id` (`TryGetDefinition`), trim/lowercase validation (`TryNormalizeKnown`), and ordered enumeration (`GetDefinitionsInIdOrder`). Stable prototype id constants: **`PrototypeNpcBehaviorRegistry`**. **NEO-90+** (HTTP definitions, instance registry, runtime tick) should inject the interface rather than `NpcBehaviorDefinitionCatalog`. Plan: [NEO-89 implementation plan](../../plans/NEO-89-implementation-plan.md).
**NPC behavior definition registry (NEO-89):** **`INpcBehaviorDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Npc/` wraps the startup-loaded catalog for read-only lookup by stable behavior `id` (`TryGetDefinition`), trim/lowercase validation (`TryNormalizeKnown`), and ordered enumeration (`GetDefinitionsInIdOrder`). Stable prototype id constants: **`PrototypeNpcBehaviorRegistry`**. **NEO-91+** (instance registry, runtime tick) should inject the interface rather than `NpcBehaviorDefinitionCatalog`. Plan: [NEO-89 implementation plan](../../plans/NEO-89-implementation-plan.md).
**NPC 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

View File

@ -165,8 +165,10 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria**
- [ ] GET returns all three defs in ascending `id` order with `schemaVersion` **1**.
- [ ] Bruno happy GET passes in CI Bruno step.
- [x] GET returns all three defs in ascending `id` order with `schemaVersion` **1**.
- [x] Bruno happy GET passes in CI Bruno step.
**Landed ([NEO-90](https://linear.app/neon-sprawl/issue/NEO-90)):** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/`; plan [NEO-90-implementation-plan.md](NEO-90-implementation-plan.md); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
---

View File

@ -43,9 +43,9 @@ No other blocking decisions — route path, `schemaVersion` **1**, registry inje
## Acceptance criteria checklist
- [ ] GET returns all **three** prototype behavior defs with **`schemaVersion`** **1** and stable field names.
- [ ] Rows appear in ascending **`id`** order (ordinal).
- [ ] Bruno happy GET passes in CI Bruno step.
- [x] GET returns all **three** prototype behavior defs with **`schemaVersion`** **1** and stable field names.
- [x] Rows appear in ascending **`id`** order (ordinal).
- [x] Bruno happy GET passes in CI Bruno step.
## Technical approach
@ -116,3 +116,10 @@ Bruno scripts complement automated tests for manual dev-server verification. No
- **`npcBehaviors`** top-level array key — matches content catalog envelope (user confirmed).
- **No manual QA doc** — server-only HTTP; Bruno + automated tests (user confirmed).
- **Registry-only injection** — HTTP layer depends on **`INpcBehaviorDefinitionRegistry`**, not **`NpcBehaviorDefinitionCatalog`**.
## Reconciliation (implementation)
- **`NpcBehaviorDefinitionsWorldApi`** + **`NpcBehaviorDefinitionsListDtos`** registered via **`MapNpcBehaviorDefinitionsWorldApi()`** in **`Program.cs`**; injects **`INpcBehaviorDefinitionRegistry`** only.
- **`NpcBehaviorDefinitionsWorldApiTests`** — integration test for schema v1, frozen-three id order, **`prototype_melee_pressure`** / **`prototype_elite_mini_boss`** spot-checks.
- **Bruno:** `bruno/neon-sprawl-server/npc-behavior-definitions/Get npc behavior definitions.bru` with response-shape tests.
- **`server/README.md`**, **E5.M2** module doc, **alignment register**, and **E5M2 backlog** updated.

View File

@ -0,0 +1,57 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
public class NpcBehaviorDefinitionsWorldApiTests
{
/// <summary>Frozen prototype three in registry id order (ordinal). Keep in sync with Bruno.</summary>
public static readonly string[] FrozenThreeInIdOrder =
[
PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss,
PrototypeNpcBehaviorRegistry.PrototypeMeleePressure,
PrototypeNpcBehaviorRegistry.PrototypeRangedControl,
];
[Fact]
public async Task GetNpcBehaviorDefinitions_ShouldReturnSchemaV1_WithFrozenThreeInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/npc-behavior-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<NpcBehaviorDefinitionsListResponse>();
Assert.NotNull(body);
Assert.Equal(NpcBehaviorDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.NpcBehaviors);
Assert.Equal(3, body.NpcBehaviors.Count);
var ids = body.NpcBehaviors.Select(static b => b.Id).ToList();
Assert.Equal(FrozenThreeInIdOrder, ids);
var melee = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeMeleePressure);
Assert.Equal("Melee Pressure", melee.DisplayName);
Assert.Equal("melee_pressure", melee.ArchetypeKind);
Assert.Equal(100, melee.MaxHp);
Assert.Equal(8.0, melee.AggroRadius);
Assert.Equal(16.0, melee.LeashRadius);
Assert.Equal(1.5, melee.TelegraphWindupSeconds);
Assert.Equal(15, melee.AttackDamage);
Assert.Equal(3.0, melee.AttackCooldownSeconds);
var elite = body.NpcBehaviors.Single(b => b.Id == PrototypeNpcBehaviorRegistry.PrototypeEliteMiniBoss);
Assert.Equal("Elite Mini-Boss", elite.DisplayName);
Assert.Equal("elite_mini_boss", elite.ArchetypeKind);
Assert.Equal(200, elite.MaxHp);
Assert.Equal(8.0, elite.AggroRadius);
Assert.Equal(18.0, elite.LeashRadius);
Assert.Equal(2.5, elite.TelegraphWindupSeconds);
Assert.Equal(25, elite.AttackDamage);
Assert.Equal(5.0, elite.AttackCooldownSeconds);
}
}

View File

@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>JSON body for <c>GET /game/world/npc-behavior-definitions</c> (NEO-90).</summary>
public sealed class NpcBehaviorDefinitionsListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Loaded behavior defs ordered by stable <c>id</c> (ordinal), matching <see cref="INpcBehaviorDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
[JsonPropertyName("npcBehaviors")]
public required IReadOnlyList<NpcBehaviorDefinitionJson> NpcBehaviors { get; init; }
}
/// <summary>One row in the read-only NPC behavior definition projection.</summary>
public sealed class NpcBehaviorDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("archetypeKind")]
public required string ArchetypeKind { get; init; }
[JsonPropertyName("maxHp")]
public required int MaxHp { get; init; }
[JsonPropertyName("aggroRadius")]
public required double AggroRadius { get; init; }
[JsonPropertyName("leashRadius")]
public required double LeashRadius { get; init; }
[JsonPropertyName("telegraphWindupSeconds")]
public required double TelegraphWindupSeconds { get; init; }
[JsonPropertyName("attackDamage")]
public required int AttackDamage { get; init; }
[JsonPropertyName("attackCooldownSeconds")]
public required double AttackCooldownSeconds { get; init; }
}

View File

@ -0,0 +1,41 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Maps <c>GET /game/world/npc-behavior-definitions</c> (NEO-90).</summary>
public static class NpcBehaviorDefinitionsWorldApi
{
public static WebApplication MapNpcBehaviorDefinitionsWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/npc-behavior-definitions",
(INpcBehaviorDefinitionRegistry registry) =>
{
var defs = registry.GetDefinitionsInIdOrder();
var npcBehaviors = new List<NpcBehaviorDefinitionJson>(defs.Count);
foreach (var d in defs)
{
npcBehaviors.Add(
new NpcBehaviorDefinitionJson
{
Id = d.Id,
DisplayName = d.DisplayName,
ArchetypeKind = d.ArchetypeKind,
MaxHp = d.MaxHp,
AggroRadius = d.AggroRadius,
LeashRadius = d.LeashRadius,
TelegraphWindupSeconds = d.TelegraphWindupSeconds,
AttackDamage = d.AttackDamage,
AttackCooldownSeconds = d.AttackCooldownSeconds,
});
}
return Results.Json(
new NpcBehaviorDefinitionsListResponse
{
SchemaVersion = NpcBehaviorDefinitionsListResponse.CurrentSchemaVersion,
NpcBehaviors = npcBehaviors,
});
});
return app;
}
}

View File

@ -54,6 +54,7 @@ app.MapSkillDefinitionsWorldApi();
app.MapItemDefinitionsWorldApi();
app.MapRecipeDefinitionsWorldApi();
app.MapAbilityDefinitionsWorldApi();
app.MapNpcBehaviorDefinitionsWorldApi();
app.MapCombatTargetsWorldApi();
app.MapResourceNodeDefinitionsWorldApi();
app.MapPlayerInventoryApi();

View File

@ -106,6 +106,14 @@ On startup the host loads every **`*_npc_behaviors.json`** under the npc-behavio
On success, **Information** logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use **`INpcBehaviorDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code. Stable prototype behavior id constants: **`PrototypeNpcBehaviorRegistry`**.
## NPC behavior definitions (NEO-90)
**`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, and **`attackCooldownSeconds`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/npc-behavior-definitions"
```
## Ability definitions (NEO-78)
**`GET /game/world/ability-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`abilities`**) backed by **`IAbilityDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`baseDamage`**, **`cooldownSeconds`**, and **`abilityKind`** when present in content. Plan: [NEO-78 implementation plan](../../docs/plans/NEO-78-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/ability-definitions/`.