From 7730bde3537811f61d5a6d782749956470704862 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 17:06:23 -0400 Subject: [PATCH 1/5] NEO-88: Add implementation plan for NPC behavior catalog load. Kickoff plan mirrors NEO-77 fail-fast loader pattern with CI-parity validation for the three frozen E5M2 behavior defs; registry deferred to NEO-89. --- docs/plans/NEO-88-implementation-plan.md | 130 +++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/plans/NEO-88-implementation-plan.md diff --git a/docs/plans/NEO-88-implementation-plan.md b/docs/plans/NEO-88-implementation-plan.md new file mode 100644 index 0000000..0250b50 --- /dev/null +++ b/docs/plans/NEO-88-implementation-plan.md @@ -0,0 +1,130 @@ +# NEO-88 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-88 | +| **Title** | E5M2-02: Server NPC behavior catalog load (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-88/e5m2-02-server-npc-behavior-catalog-load-fail-fast | +| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-02** | +| **Branch** | `NEO-88-e5m2-server-npc-behavior-catalog-load` | +| **Precursor** | [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) — frozen three-behavior catalog + CI gates (**Done** on `main`) | +| **Pattern** | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket | +| **Blocks** | [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — `INpcBehaviorDefinitionRegistry` + DI (E5M2-03) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **NEO-88 vs NEO-89 (DI scope)** | Ship `INpcBehaviorDefinitionRegistry` on this story, or only load + `NpcBehaviorDefinitionCatalog`? | **Strict split** — NEO-88: loader + `NpcBehaviorDefinitionCatalog` + eager fail-fast boot; [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) adds injectable `INpcBehaviorDefinitionRegistry` (E5M2-03). Rationale: E5M2 backlog separates E5M2-02/03; mirrors NEO-77/NEO-79. | **User:** `NpcBehaviorDefinitionCatalog` singleton only (kickoff). | +| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `AbilityDefinitionCatalogLoaderTests` (NEO-77). | **User:** loader AAA + host boot tests (kickoff). | +| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + E5M2 three-id gate + leash > aggro numeric gate). Rationale: NEO-77 precedent; no cross-catalog refs in Slice 2. | **Adopted** — NEO-77 precedent; no separate question. | +| **Client counterpart** | Godot issue for this story? | **None** — server-only load; player-visible work starts at E5M2-04+ / [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) / [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). | **Adopted** (E5M2-02 out of scope). | + +## Goal, scope, and out-of-scope + +**Goal:** On host startup, load all `content/npc-behaviors/*_npc_behaviors.json` catalogs (validated in CI per [NEO-87](NEO-87-implementation-plan.md)), build an in-memory **`NpcBehaviorDefinitionCatalog`**, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, fails the prototype E5M2 three-id gate, or violates the leash > aggro numeric invariant. + +**In scope (from Linear + [E5M2-02](E5M2-prototype-backlog.md#e5m2-02--server-npc-behavior-catalog-load-fail-fast)):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Npc/`. +- Configurable npc-behaviors directory and schema path (`ContentPathsOptions` extension). +- CI-parity validation: `schemaVersion`, row schema (`npc-behavior-def`), duplicate `id`, E5M2 three-id allowlist gate, **`leashRadius > aggroRadius`** per row. +- DI registration of **`NpcBehaviorDefinitionCatalog`** singleton; eager resolve in `Program.cs`. +- Unit + host startup tests (AAA). +- `server/README.md` section. + +**Out of scope (from Linear):** + +- **`INpcBehaviorDefinitionRegistry`** / DI registry (**NEO-89**, E5M2-03). +- HTTP projection (**NEO-90**, E5M2-04), NPC runtime tick, dummy migration (E5M2-05+). + +## Acceptance criteria checklist + +- [ ] Server refuses boot when NPC behavior catalog invalid (missing dir, no `*_npc_behaviors.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M2 gate failure, leash ≤ aggro). +- [ ] `NpcBehaviorDefinitionCatalog` resolves prototype rows by behavior `id` (e.g. `prototype_melee_pressure`) with all schema fields on the row DTO. +- [ ] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. +- [ ] Success log includes **behavior count**, **catalog directory**, and **file count** (Information). + +## Technical approach + +1. **`Game/Npc/` row DTO** — `NpcBehaviorDefRow` (`Id`, `DisplayName`, `ArchetypeKind`, `MaxHp`, `AggroRadius`, `LeashRadius`, `TelegraphWindupSeconds`, `AttackDamage`, `AttackCooldownSeconds`). `ArchetypeKind` stored as `string` matching schema enum values (mirror `AbilityDefRow.AbilityKind` pattern). + +2. **`NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDirectory, schemaPath, logger)`** — single-pass load mirroring `validate_content.py` `_validate_npc_behavior_catalogs` + `_prototype_e5m2_npc_behavior_gate` + `_prototype_e5m2_npc_behavior_numeric_gate`: + - Enumerate `*_npc_behaviors.json` (top-level only), ordered by path. + - Build **JsonSchema.Net** evaluator from `npc-behavior-def.schema.json`. + - Per file: parse JSON; require `schemaVersion == 1` and top-level `npcBehaviors` array. + - Per row: validate against schema; on clean rows track `id`; build `NpcBehaviorDefRow`; reject duplicate `id` across files. + - Run **`PrototypeE5M2NpcBehaviorCatalogRules.TryGetE5M2GateError`** (mirror `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` / `_prototype_e5m2_npc_behavior_gate`). + - Run **`PrototypeE5M2NpcBehaviorCatalogRules.TryGetNumericGateError`** (mirror `_prototype_e5m2_npc_behavior_numeric_gate`: `leashRadius > aggroRadius`). + - Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `NPC behavior catalog validation failed:`. + +3. **`NpcBehaviorDefinitionCatalog`** — immutable snapshot: `NpcBehaviorsDirectory`, `ById`, `DistinctBehaviorCount`, `CatalogJsonFileCount`; `TryGetBehavior(id, out NpcBehaviorDefRow?)` for lookups. + +4. **`NpcBehaviorCatalogPathResolution`** — `TryDiscoverNpcBehaviorsDirectory`, `ResolveNpcBehaviorsDirectory`, `ResolveNpcBehaviorDefSchemaPath` (parent `content/schemas/` layout when unset). + +5. **`PrototypeE5M2NpcBehaviorCatalogRules`** — frozen three behavior ids; sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS`. Ids: `prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss` (same set as [E5.M2 freeze table](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md#prototype-slice-2-freeze-e5m2-01)). + +6. **`NpcBehaviorCatalogServiceCollectionExtensions.AddNpcBehaviorDefinitionCatalog`** — bind `ContentPathsOptions`; register **`NpcBehaviorDefinitionCatalog`** singleton factory that resolves paths and calls loader. No dependency on ability/item catalogs (Slice 2 behavior rows have no cross-ref fields). + +7. **`Program.cs`** — `AddNpcBehaviorDefinitionCatalog(configuration)` alongside existing catalog registrations; after `Build()`, `GetRequiredService()` with other eager catalog resolves. + +8. **Tests** — temp-dir fixtures copying repo `npc-behavior-def.schema.json`; valid three-behavior JSON matching [prototype_npc_behaviors.json](../../content/npc-behaviors/prototype_npc_behaviors.json); negative cases (duplicate id, wrong/missing E5M2 roster, bad numeric fields, empty `displayName`, `schemaVersion` ≠ 1, missing dir/schema, no `*_npc_behaviors.json`, invalid JSON, `leashRadius <= aggroRadius`); `InMemoryWebApplicationFactory` host test + `WebApplicationFactory` with empty npc-behaviors dir fails startup. + +9. **Test factories** — pin `Content:NpcBehaviorsDirectory` in `InMemoryWebApplicationFactory` and other `WebApplicationFactory` subclasses that already pin recipes/items/abilities (same pattern as NEO-77). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs` | Load-time `NpcBehaviorDef` row DTO. | +| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs` | In-memory catalog + `TryGetBehavior`. | +| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs` | Disk I/O, schema validation, E5M2 gates. | +| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs` | Directory/schema discovery and config resolution. | +| `server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs` | Frozen three ids + numeric gate (sync comment to Python). | +| `server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs` | `AddNpcBehaviorDefinitionCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs` | Repo `content/npc-behaviors` + schema discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. | +| `docs/plans/NEO-88-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `NpcBehaviorsDirectory`, `NpcBehaviorDefSchemaPath` under `Content` section. | +| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `NpcBehaviorDefinitionCatalog` at startup. | +| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:NpcBehaviorsDirectory` / schema path keys for override documentation (mirror other catalogs). | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:NpcBehaviorsDirectory` to discovered repo path so existing tests keep booting. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Pin npc-behaviors directory (same as recipes/items/abilities). | +| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Pin npc-behaviors directory. | +| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Pin npc-behaviors directory. | +| `server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs` | Pin npc-behaviors directory. | +| `server/README.md` | New **NPC behavior catalog (`content/npc-behaviors`, NEO-88)** section (config keys, discovery, fail-fast, E5M2 parity with CI). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M2 row — note NEO-88 server load when implementation completes. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | Status line — E5M2-02 server load landed when complete. | +| `docs/plans/E5M2-prototype-backlog.md` | E5M2-02 checkboxes when implementation completes. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs` | **Unit:** valid three-behavior prototype catalog loads; `npcBehaviors` not array; schema violation (zero `aggroRadius`, empty `displayName`, invalid `maxHp`); duplicate `id` across files; E5M2 gate failure (missing id + extra/wrong id); numeric gate failure (`leashRadius <= aggroRadius`); `schemaVersion` ≠ 1; missing dir/schema; no `*_npc_behaviors.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `NpcBehaviorDefinitionCatalog` count 3; `WebApplicationFactory` with empty npc-behaviors dir fails startup with actionable message. | + +**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA loader/host tests above. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Constants drift vs Python** | Comment on `PrototypeE5M2NpcBehaviorCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS` / gate helpers. | **adopted** | +| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:NpcBehaviorsDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** | +| **Registry consumers** | Runtime NPC logic uses `INpcBehaviorDefinitionRegistry` in NEO-89+; this story only exposes `NpcBehaviorDefinitionCatalog` for boot + direct test lookup. | **adopted** | +| **Alpha/beta dummies still active** | Expected — catalog lands before E5M2-05 migrates combat targets to NPC instance ids. | **adopted** | +| **Numeric gate not in JSON Schema** | Enforce in C# gate class (same as Python `_prototype_e5m2_npc_behavior_numeric_gate`); schema only requires strictly positive radii. | **adopted** | + +## Decisions (kickoff) + +- **`NpcBehaviorDefinitionCatalog` singleton only** — defer `INpcBehaviorDefinitionRegistry` to NEO-89 (user confirmed kickoff). +- **In-process validation** + **loader and host boot tests** per NEO-77 precedent. +- **No cross-catalog checks** — behavior rows have no ability/item refs in Slice 2; loader has no catalog dependencies. From 83282e76f06c80984a79eff9b7fcdb2e27fa5e31 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 17:08:40 -0400 Subject: [PATCH 2/5] NEO-88: Fail-fast NPC behavior catalog load at server startup. Mirror NEO-77 ability loader: JsonSchema.Net validation, E5M2 three-id gate, leash > aggro numeric gate, DI singleton, Bruno health smoke, and 16 AAA loader/host tests. --- ...Health after npc behavior catalog load.bru | 25 ++ .../npc-behavior-catalog/folder.bru | 3 + ...vityDeniedRegistryWebApplicationFactory.cs | 5 + .../Game/Npc/NpcBehaviorCatalogTestPaths.cs | 17 + ...NpcBehaviorDefinitionCatalogLoaderTests.cs | 379 ++++++++++++++++++ .../PostgresWebApplicationFactory.cs | 5 + ...wardDeniedRegistryWebApplicationFactory.cs | 5 + ...vityDeniedRegistryWebApplicationFactory.cs | 5 + .../InMemoryWebApplicationFactory.cs | 5 + .../Npc/NpcBehaviorCatalogPathResolution.cs | 60 +++ ...aviorCatalogServiceCollectionExtensions.cs | 37 ++ .../Game/Npc/NpcBehaviorDefRow.cs | 13 + .../Game/Npc/NpcBehaviorDefinitionCatalog.cs | 25 ++ .../Npc/NpcBehaviorDefinitionCatalogLoader.cs | 208 ++++++++++ .../PrototypeE5M2NpcBehaviorCatalogRules.cs | 50 +++ .../Game/Skills/ContentPathsOptions.cs | 12 + server/NeonSprawl.Server/Program.cs | 3 + server/NeonSprawl.Server/appsettings.json | 4 +- server/README.md | 13 + 19 files changed, 873 insertions(+), 1 deletion(-) create mode 100644 bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru create mode 100644 bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs diff --git a/bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru b/bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru new file mode 100644 index 0000000..17bd525 --- /dev/null +++ b/bruno/neon-sprawl-server/npc-behavior-catalog/Health after npc behavior catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (npc behavior catalog boot NEO-88) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-88 loads content/npc-behaviors/*_npc_behaviors.json at startup (fail-fast). No NPC behavior HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru b/bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru new file mode 100644 index 0000000..74951ca --- /dev/null +++ b/bruno/neon-sprawl-server/npc-behavior-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: npc-behavior-catalog +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index c1f96b7..7a1ae90 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -11,6 +11,7 @@ using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -45,12 +46,16 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); + builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.ConfigureTestServices(services => diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs new file mode 100644 index 0000000..0b41c6b --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Npc; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +internal static class NpcBehaviorCatalogTestPaths +{ + internal static string DiscoverRepoNpcBehaviorsDirectory() => + NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoNpcBehaviorDefSchemaPath() => + NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath( + DiscoverRepoNpcBehaviorsDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..b595075 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs @@ -0,0 +1,379 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Npc; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Npc; + +public class NpcBehaviorDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "npcBehaviors": [ + { + "id": "prototype_melee_pressure", + "displayName": "Melee Pressure", + "archetypeKind": "melee_pressure", + "maxHp": 100, + "aggroRadius": 8.0, + "leashRadius": 16.0, + "telegraphWindupSeconds": 1.5, + "attackDamage": 15, + "attackCooldownSeconds": 3.0 + }, + { + "id": "prototype_ranged_control", + "displayName": "Ranged Control", + "archetypeKind": "ranged_control", + "maxHp": 80, + "aggroRadius": 10.0, + "leashRadius": 20.0, + "telegraphWindupSeconds": 2.0, + "attackDamage": 12, + "attackCooldownSeconds": 4.0 + }, + { + "id": "prototype_elite_mini_boss", + "displayName": "Elite Mini-Boss", + "archetypeKind": "elite_mini_boss", + "maxHp": 200, + "aggroRadius": 8.0, + "leashRadius": 18.0, + "telegraphWindupSeconds": 2.5, + "attackDamage": 25, + "attackCooldownSeconds": 5.0 + } + ] + } + """; + + private static (string Root, string NpcBehaviorsDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-npcbehaviorcat-"); + var npcBehaviorsDir = Path.Combine(root.FullName, "content", "npc-behaviors"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(npcBehaviorsDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "npc-behavior-def.schema.json"); + File.Copy(NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, npcBehaviorsDir, schemaPath); + } + + private static void WriteCatalog(string npcBehaviorsDir, string catalogJson) => + File.WriteAllText(Path.Combine(npcBehaviorsDir, "prototype_npc_behaviors.json"), catalogJson, Encoding.UTF8); + + private static JsonObject GetBehaviorRow(JsonObject catalogRoot, string behaviorId) + { + var npcBehaviors = catalogRoot["npcBehaviors"] as JsonArray + ?? throw new InvalidOperationException("expected npcBehaviors array"); + foreach (var node in npcBehaviors) + { + if (node is JsonObject row && row["id"]?.GetValue() == behaviorId) + return row; + } + + throw new InvalidOperationException($"npc behavior id not found: {behaviorId}"); + } + + private static NpcBehaviorDefinitionCatalog LoadCatalog(string npcBehaviorsDir, string schemaPath) => + NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(npcBehaviorsDir, schemaPath); + // Assert + Assert.Equal(3, catalog.DistinctBehaviorCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetBehavior("prototype_melee_pressure", out var melee)); + Assert.NotNull(melee); + 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(15, melee.AttackDamage); + } + + [Fact] + public void Load_ShouldThrow_WhenNpcBehaviorsIsNotArray() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"), + """{"schemaVersion": 1, "npcBehaviors": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'npcBehaviors' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"), + """{"schemaVersion": 2, "npcBehaviors": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateBehaviorIdAcrossFiles() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(npcBehaviorsDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(npcBehaviorsDir, "extra_npc_behaviors.json"), + """ + { + "schemaVersion": 1, + "npcBehaviors": [ + { + "id": "prototype_melee_pressure", + "displayName": "Duplicate Melee", + "archetypeKind": "melee_pressure", + "maxHp": 1, + "aggroRadius": 1.0, + "leashRadius": 2.0, + "telegraphWindupSeconds": 1.0, + "attackDamage": 1, + "attackCooldownSeconds": 1.0 + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate npc behavior id 'prototype_melee_pressure'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M2GateFailsWithMissingId() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + var npcBehaviors = root["npcBehaviors"] as JsonArray + ?? throw new InvalidOperationException("expected npcBehaviors array"); + npcBehaviors.RemoveAt(npcBehaviors.Count - 1); + WriteCatalog(npcBehaviorsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M2GateFailsWithExtraId() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetBehaviorRow(root, "prototype_elite_mini_boss")["id"] = "prototype_extra"; + WriteCatalog(npcBehaviorsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M2 expects exactly npc behavior ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenLeashRadiusIsNotGreaterThanAggroRadius() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetBehaviorRow(root, "prototype_melee_pressure")["leashRadius"] = 8.0; + WriteCatalog(npcBehaviorsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("leashRadius 8 must be > aggroRadius 8", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenAggroRadiusIsZero() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetBehaviorRow(root, "prototype_melee_pressure")["aggroRadius"] = 0; + WriteCatalog(npcBehaviorsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("aggroRadius", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenMaxHpIsZero() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetBehaviorRow(root, "prototype_melee_pressure")["maxHp"] = 0; + WriteCatalog(npcBehaviorsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("maxHp", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDisplayNameIsEmpty() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetBehaviorRow(root, "prototype_melee_pressure")["displayName"] = ""; + WriteCatalog(npcBehaviorsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("NPC behavior catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenNoNpcBehaviorCatalogFiles() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("no *_npc_behaviors.json files", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenJsonIsInvalid() + { + // Arrange + var (_, npcBehaviorsDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(npcBehaviorsDir, "bad_npc_behaviors.json"), + "{not json", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(npcBehaviorsDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_npc_behaviors.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenNpcBehaviorsDirectoryMissing() + { + // Arrange + var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-npc-behaviors-" + Guid.NewGuid().ToString("n")); + var schemaPath = NpcBehaviorCatalogTestPaths.DiscoverRepoNpcBehaviorDefSchemaPath(); + // Act + var ex = Record.Exception(() => + NpcBehaviorDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaFileMissing() + { + // Arrange + var (_, npcBehaviorsDir, _) = CreateTempContentLayout(); + var missingSchema = Path.Combine(npcBehaviorsDir, "missing-npc-behavior-def.schema.json"); + // Act + var ex = Record.Exception(() => + NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, missingSchema, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var catalog = factory.Services.GetRequiredService(); + Assert.Equal(3, catalog.DistinctBehaviorCount); + Assert.True(catalog.TryGetBehavior("prototype_elite_mini_boss", out var elite)); + Assert.Equal(200, elite!.MaxHp); + Assert.Equal(25, elite.AttackDamage); + Assert.Equal(5.0, elite.AttackCooldownSeconds); + } + + [Fact] + public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-npc-behaviors-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:NpcBehaviorsDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("NPC behavior catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index dd6072b..f52eeb0 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Tests.Game.PositionState; @@ -43,12 +44,16 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs index fb336d1..b4bcd82 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs @@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -37,10 +38,14 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); + builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs index b0b9a23..5d46c3e 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -37,10 +38,14 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli var abilitiesDir = AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); + builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 65b2821..52b36c9 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.Skills; using Npgsql; @@ -47,12 +48,16 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs new file mode 100644 index 0000000..15e59fe --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Resolves NPC behavior catalog paths for local dev, tests, and container layouts (NEO-88). +public static class NpcBehaviorCatalogPathResolution +{ + /// Walks and parents for an existing content/npc-behaviors directory. + public static string? TryDiscoverNpcBehaviorsDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "npc-behaviors"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the npc-behaviors catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveNpcBehaviorsDirectory(string? configuredNpcBehaviorsDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredNpcBehaviorsDirectory)) + { + var discovered = TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:NpcBehaviorsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/npc-behaviors'). " + + "Set Content:NpcBehaviorsDirectory in configuration or environment (e.g. Content__NpcBehaviorsDirectory)."); + } + + var trimmed = configuredNpcBehaviorsDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single NPC behavior row (Draft 2020-12). + public static string ResolveNpcBehaviorDefSchemaPath( + string npcBehaviorsDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(npcBehaviorsDirectory, "..", "schemas", "npc-behavior-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..9f16625 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Npc; + +/// DI registration for the fail-fast NPC behavior catalog (NEO-88). +public static class NpcBehaviorCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddNpcBehaviorDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Npc.NpcBehaviorCatalog"); + + var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorsDirectory( + opts.NpcBehaviorsDirectory, + hostEnv.ContentRootPath); + var schemaPath = NpcBehaviorCatalogPathResolution.ResolveNpcBehaviorDefSchemaPath( + npcBehaviorsDir, + opts.NpcBehaviorDefSchemaPath, + hostEnv.ContentRootPath); + + return NpcBehaviorDefinitionCatalogLoader.Load(npcBehaviorsDir, schemaPath, logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs new file mode 100644 index 0000000..1f93d9a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs @@ -0,0 +1,13 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// One validated NpcBehaviorDef row from content/npc-behaviors/*_npc_behaviors.json (NEO-88). +public sealed record NpcBehaviorDefRow( + string Id, + string DisplayName, + string ArchetypeKind, + int MaxHp, + double AggroRadius, + double LeashRadius, + double TelegraphWindupSeconds, + int AttackDamage, + double AttackCooldownSeconds); diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs new file mode 100644 index 0000000..14d2264 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Npc; + +/// In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use INpcBehaviorDefinitionRegistry (NEO-89). +public sealed class NpcBehaviorDefinitionCatalog( + string npcBehaviorsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_npc_behaviors.json catalogs. + public string NpcBehaviorsDirectory { get; } = npcBehaviorsDirectory; + + public IReadOnlyDictionary ById { get; } = + new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctBehaviorCount => ById.Count; + + /// Number of *_npc_behaviors.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable behavior . + public bool TryGetBehavior(string id, out NpcBehaviorDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs new file mode 100644 index 0000000..be7cddd --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs @@ -0,0 +1,208 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Npc; + +/// Loads and validates content/npc-behaviors/*_npc_behaviors.json using the same rules as scripts/validate_content.py (NEO-88). +public static class NpcBehaviorDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static NpcBehaviorDefinitionCatalog Load(string npcBehaviorsDirectory, string schemaPath, ILogger logger) + { + npcBehaviorsDirectory = Path.GetFullPath(npcBehaviorsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(npcBehaviorsDirectory)) + errors.Add($"error: missing directory {npcBehaviorsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(npcBehaviorsDirectory, "*_npc_behaviors.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_npc_behaviors.json files under {npcBehaviorsDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var behaviorIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var npcBehaviorsNode = rootObj["npcBehaviors"]; + if (npcBehaviorsNode is not JsonArray npcBehaviorsArray) + { + errors.Add($"error: {path}: expected top-level 'npcBehaviors' array"); + continue; + } + + for (var i = 0; i < npcBehaviorsArray.Count; i++) + { + var behavior = npcBehaviorsArray[i]; + if (behavior is not JsonObject rowObj) + { + errors.Add($"error: {path}: npcBehaviors[{i}] must be an object"); + continue; + } + + var eval = schema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} npcBehaviors[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var bid = (rowObj["id"] as JsonValue)?.GetValue(); + if (bid is not null && rowSchemaErrors == 0) + { + if (behaviorIdToSourceFile.TryGetValue(bid, out var prevPath)) + { + errors.Add($"error: duplicate npc behavior id '{bid}' in {prevPath} and {path}"); + continue; + } + + behaviorIdToSourceFile[bid] = path; + rows[bid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var e5m2 = PrototypeE5M2NpcBehaviorCatalogRules.TryGetE5M2GateError(behaviorIdToSourceFile); + if (e5m2 is not null) + { + errors.Add(e5m2); + ThrowIfAny(errors); + } + + var numeric = PrototypeE5M2NpcBehaviorCatalogRules.TryGetNumericGateError(rows); + if (numeric is not null) + { + errors.Add(numeric); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded NPC behavior catalog from {NpcBehaviorsDirectory}: {BehaviorCount} behavior(s) across {CatalogFileCount} JSON catalog file(s).", + npcBehaviorsDirectory, + rows.Count, + jsonFiles.Length); + } + + return new NpcBehaviorDefinitionCatalog(npcBehaviorsDirectory, rows, jsonFiles.Length); + } + + private static NpcBehaviorDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var archetypeKind = (rowObj["archetypeKind"] as JsonValue)!.GetValue(); + var maxHp = (rowObj["maxHp"] as JsonValue)!.GetValue(); + var aggroRadius = (rowObj["aggroRadius"] as JsonValue)!.GetValue(); + var leashRadius = (rowObj["leashRadius"] as JsonValue)!.GetValue(); + var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue(); + var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue(); + var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue(); + + return new NpcBehaviorDefRow( + id, + displayName, + archetypeKind, + maxHp, + aggroRadius, + leashRadius, + telegraphWindupSeconds, + attackDamage, + attackCooldownSeconds); + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} npcBehaviors[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("NPC behavior catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs new file mode 100644 index 0000000..3bb2de9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs @@ -0,0 +1,50 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Prototype E5M2 roster + numeric gates (NEO-87 / NEO-88), mirrored from scripts/validate_content.py +/// PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS, _prototype_e5m2_npc_behavior_gate, and +/// _prototype_e5m2_npc_behavior_numeric_gate. +/// +public static class PrototypeE5M2NpcBehaviorCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS. + public static readonly FrozenSet ExpectedBehaviorIds = FrozenSet.ToFrozenSet( + [ + "prototype_melee_pressure", + "prototype_ranged_control", + "prototype_elite_mini_boss", + ], + StringComparer.Ordinal); + + /// Returns a human-readable error if the E5M2 behavior contract fails, otherwise . + public static string? TryGetE5M2GateError(IReadOnlyDictionary behaviorIdToSourceFile) + { + var ids = behaviorIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedBehaviorIds)) + { + return + "error: prototype E5M2 expects exactly npc behavior ids " + + $"[{string.Join(", ", ExpectedBehaviorIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error when leashRadius <= aggroRadius, otherwise . + public static string? TryGetNumericGateError(IReadOnlyDictionary rowsById) + { + foreach (var (bid, row) in rowsById) + { + if (row.LeashRadius <= row.AggroRadius) + { + return + $"error: npc behavior '{bid}': leashRadius {row.LeashRadius} must be > aggroRadius {row.AggroRadius}"; + } + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 93da821..d04a590 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -88,4 +88,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of abilities directory}/schemas/ability-def.schema.json. /// public string? AbilityDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/npc-behaviors directory. + /// + public string? NpcBehaviorsDirectory { get; set; } + + /// + /// Optional override for npc-behavior-def.schema.json. + /// When unset, resolved as {parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json. + /// + public string? NpcBehaviorDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index f10b458..d3b7020 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -6,6 +6,7 @@ using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Npc; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Targeting; @@ -23,6 +24,7 @@ builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); +builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); @@ -31,6 +33,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index b13ffb7..313e931 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -15,7 +15,9 @@ "RecipeDefSchemaPath": "", "RecipeIoRowSchemaPath": "", "AbilitiesDirectory": "", - "AbilityDefSchemaPath": "" + "AbilityDefSchemaPath": "", + "NpcBehaviorsDirectory": "", + "NpcBehaviorDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index aeda2c6..ab9a4e9 100644 --- a/server/README.md +++ b/server/README.md @@ -93,6 +93,19 @@ On startup the host loads every **`*_abilities.json`** under the abilities direc On success, **Information** logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use **`IAbilityDefinitionRegistry`** for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; do not inject **`AbilityDefinitionCatalog`** in new game code. Hotbar loadout and ability cast routes validate ability ids via **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (backed by the loaded catalog, not a separate static allowlist). +## NPC behavior catalog (`content/npc-behaviors`, NEO-88) + +On startup the host loads every **`*_npc_behaviors.json`** under the npc-behaviors directory, validates each row against **`content/schemas/npc-behavior-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, enforces the **prototype E5M2** three-id roster gate, and requires **`leashRadius > aggroRadius`** per row (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:NpcBehaviorsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/npc-behaviors`**. | +| **`Content:NpcBehaviorDefSchemaPath`** | Optional override for **`npc-behavior-def.schema.json`**. When unset, **`{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json`**. | + +**Docker / CI:** include **`content/npc-behaviors`** and **`content/schemas/npc-behavior-def.schema.json`** in the mounted **`content/`** tree; set **`Content__NpcBehaviorsDirectory`** when layout differs. + +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 (NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code. + ## 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/`. From c56ad22f6f6f8f839fb0908ed31a78a0cc24a364 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 17:08:40 -0400 Subject: [PATCH 3/5] NEO-88: Reconcile plan, backlog, and module docs for server NPC load. Mark E5M2-02 acceptance complete and update alignment register with NEO-88 server catalog load landing notes. --- .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 4 ++-- ...cumentation_and_implementation_alignment.md | 2 +- docs/plans/E5M2-prototype-backlog.md | 6 ++++-- docs/plans/NEO-88-implementation-plan.md | 18 ++++++++++++++---- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index f7e165d..bf0842a 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -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-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-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-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-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | ## Purpose diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5d722b3..04af82e 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -57,7 +57,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); 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](../../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, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../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`** ([NEO-83](../../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](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../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 — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **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); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | -| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 4eb64f7..033bb26 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -119,8 +119,10 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d **Acceptance criteria** -- [ ] Host fails startup on invalid NPC behavior JSON (mirror CI rules). -- [ ] Tests cover at least one happy path and one malformed catalog rejection. +- [x] Host fails startup on invalid NPC behavior JSON (mirror CI rules). +- [x] Tests cover at least one happy path and one malformed catalog rejection. + +**Landed ([NEO-88](https://linear.app/neon-sprawl/issue/NEO-88)):** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/`; plan [NEO-88-implementation-plan.md](NEO-88-implementation-plan.md). --- diff --git a/docs/plans/NEO-88-implementation-plan.md b/docs/plans/NEO-88-implementation-plan.md index 0250b50..1c9171a 100644 --- a/docs/plans/NEO-88-implementation-plan.md +++ b/docs/plans/NEO-88-implementation-plan.md @@ -42,10 +42,20 @@ ## Acceptance criteria checklist -- [ ] Server refuses boot when NPC behavior catalog invalid (missing dir, no `*_npc_behaviors.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M2 gate failure, leash ≤ aggro). -- [ ] `NpcBehaviorDefinitionCatalog` resolves prototype rows by behavior `id` (e.g. `prototype_melee_pressure`) with all schema fields on the row DTO. -- [ ] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. -- [ ] Success log includes **behavior count**, **catalog directory**, and **file count** (Information). +- [x] Server refuses boot when NPC behavior catalog invalid (missing dir, no `*_npc_behaviors.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M2 gate failure, leash ≤ aggro). +- [x] `NpcBehaviorDefinitionCatalog` resolves prototype rows by behavior `id` (e.g. `prototype_melee_pressure`) with all schema fields on the row DTO. +- [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. +- [x] Success log includes **behavior count**, **catalog directory**, and **file count** (Information). + +## Implementation reconciliation (shipped) + +- **Loader:** [`NpcBehaviorDefinitionCatalogLoader`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalogLoader.cs) — CI-parity validation via JsonSchema.Net + E5M2 id gate + leash > aggro numeric gate. +- **Catalog:** [`NpcBehaviorDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefinitionCatalog.cs) + [`NpcBehaviorDefRow`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorDefRow.cs) — `TryGetBehavior` lookup by id. +- **Gate rules:** [`PrototypeE5M2NpcBehaviorCatalogRules`](../../server/NeonSprawl.Server/Game/Npc/PrototypeE5M2NpcBehaviorCatalogRules.cs) — sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS`. +- **DI / startup:** [`NpcBehaviorCatalogServiceCollectionExtensions`](../../server/NeonSprawl.Server/Game/Npc/NpcBehaviorCatalogServiceCollectionExtensions.cs) + eager resolve in [`Program.cs`](../../server/NeonSprawl.Server/Program.cs). +- **Tests:** [`NpcBehaviorDefinitionCatalogLoaderTests`](../../server/NeonSprawl.Server.Tests/Game/Npc/NpcBehaviorDefinitionCatalogLoaderTests.cs) — 16 AAA cases (loader + host). +- **Docs:** `server/README.md` NPC behavior catalog section; E5.M2 module doc + alignment register + E5M2 backlog E5M2-02 checkboxes. +- **Unchanged:** No `INpcBehaviorDefinitionRegistry` — migration deferred to NEO-89. ## Technical approach From 3ad8581411bfde4a8ec7065dd0dcae05b9ca0f22 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 17:10:31 -0400 Subject: [PATCH 4/5] NEO-88: Add code review for NPC behavior catalog load branch. --- docs/reviews/2026-05-25-NEO-88.md | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/reviews/2026-05-25-NEO-88.md diff --git a/docs/reviews/2026-05-25-NEO-88.md b/docs/reviews/2026-05-25-NEO-88.md new file mode 100644 index 0000000..dc450ee --- /dev/null +++ b/docs/reviews/2026-05-25-NEO-88.md @@ -0,0 +1,62 @@ +# Code review — NEO-88 server NPC behavior catalog load (fail-fast) + +**Date:** 2026-05-25 +**Scope:** Branch `NEO-88-e5m2-server-npc-behavior-catalog-load` · commits `7730bde`–`c56ad22` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-88 delivers **E5M2-02**: fail-fast startup load of `content/npc-behaviors/*_npc_behaviors.json` under `server/NeonSprawl.Server/Game/Npc/`, mirroring the NEO-77 ability-catalog pattern. The loader validates Draft 2020-12 row schema, `schemaVersion`, duplicate `id`, the frozen E5M2 three-id gate (`PrototypeE5M2NpcBehaviorCatalogRules`), and the `leashRadius > aggroRadius` numeric gate; registers `NpcBehaviorDefinitionCatalog` in DI; and eagerly resolves it in `Program.cs`. Sixteen AAA loader/host tests pass. Docs (plan, E5M2 backlog, E5.M2 module status, alignment register, `server/README.md`) are updated. Bruno adds a health smoke request with `folder.bru`. `INpcBehaviorDefinitionRegistry` is correctly deferred to NEO-89. No client or NPC HTTP API — appropriately scoped. Risk is low: infrastructure-only; CI already gates content via NEO-87. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-88-implementation-plan.md`](../plans/NEO-88-implementation-plan.md) | **Matches** — kickoff decisions, loader/catalog/DI scope, acceptance checklist, reconciliation section; registry correctly out of scope. | +| [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-02** | **Matches** — AC checkboxes + landed note. | +| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Partially matches** — Status line cites NEO-88 server load; freeze **Rules** paragraph still links only NEO-87 / `validate_content.py` (E5.M1 cites NEO-77 server startup in the same section). | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — NEO-87 + NEO-88 landed notes; next backlog item NEO-89. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E5.M2 **Status** **In Progress** is correct; **E5.M2 note** cites NEO-87 only — add NEO-88 server load (mirror E5.M1 note after NEO-77). | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server startup catalog only; no client mutation or NPC runtime. | +| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — C# loader mirrors `_validate_npc_behavior_catalogs` + E5M2 id/numeric gates; sync comment on `PrototypeE5M2NpcBehaviorCatalogRules.ExpectedBehaviorIds`. | +| [`server/README.md`](../../server/README.md) | **Matches** — NPC behavior catalog section (config keys, discovery, fail-fast, E5M2 parity, NEO-89 registry deferral). | +| Full-stack epic decomposition | **Matches** — E5M2-02 is **server/content** only; kickoff defers Godot to E5M2-04+ / NEO-98; no false “prototype complete” claim. | +| Manual QA | **N/A** — plan documents skip rationale: automated loader/host tests + Bruno health smoke. | + +Register/tracking: alignment table E5.M2 **In Progress** with NEO-88 note is correct; module register **E5.M2 note** should mention NEO-88. + +## Blocking issues + +None. + +## Suggestions + +1. **Update `module_dependency_register.md` E5.M2 note** — Append **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` with link to [NEO-88 plan](../plans/NEO-88-implementation-plan.md) and [server README — NPC behavior catalog](../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88) (same pattern as E5.M1 note after NEO-77). + +2. **E5.M2 freeze Rules paragraph** — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend the CI sentence to cite server startup enforcement via NEO-88 (mirror E5.M1 freeze rules referencing NEO-77). + +## Nits + +- Nit: `ValidPrototypeCatalogJson` duplicates `content/npc-behaviors/prototype_npc_behaviors.json` inline — fine for isolated temp-dir fixtures (same pattern as NEO-77 ability tests); optional future refactor to copy repo file. + +- Nit: E5M2 gate error string format differs slightly from Python (`'prototype_melee_pressure', …` vs `sorted(...)!r`); behavior is equivalent and messages are actionable. + +- Nit: Repeated `services.AddOptions().Bind(...)` in each catalog extension — established repo pattern (NEO-51/NEO-66/NEO-77); harmless duplicate binds. + +## Verification + +```bash +cd /home/don/neon-sprawl/server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~NpcBehaviorDefinitionCatalogLoaderTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj + +# Content CI parity (repo root) +python3 scripts/validate_content.py +# Expect: … 1 npc behavior catalog file(s) … 3 unique npc behavior id(s) +``` + +Bruno: `bruno/neon-sprawl-server/npc-behavior-catalog/` — GET `/health` smoke after catalog boot. From ba776b9b33ddc7afb3404d3cb8bfcce786ab3a4e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 25 May 2026 17:11:22 -0400 Subject: [PATCH 5/5] NEO-88: Address code review doc gaps for E5.M2 tracking. Extend module register E5.M2 note and freeze Rules with NEO-88 server startup enforcement; strike through resolved review suggestions. --- .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/reviews/2026-05-25-NEO-88.md | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index bf0842a..baf004e 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -75,7 +75,7 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j **NPC instance ids (E5M2-05):** **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** — each binds one behavior def + world anchor (replaces **`prototype_target_alpha` / `beta`**). -**Rules:** Do **not** rename frozen behavior **`id`** values without a migration issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) enforces **exactly** these three behavior ids, duplicate-`id` rejection, and **`leashRadius` > `aggroRadius`** per row. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows. Plan: [NEO-87 implementation plan](../../plans/NEO-87-implementation-plan.md). +**Rules:** Do **not** rename frozen behavior **`id`** values without a migration issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) and server startup ([NEO-88](../../plans/NEO-88-implementation-plan.md)) enforce **exactly** these three behavior ids, duplicate-`id` rejection, and **`leashRadius` > `aggroRadius`** per row. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows. Catalog + CI plan: [NEO-87 implementation plan](../../plans/NEO-87-implementation-plan.md). ## Risks and telemetry diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 85bc6ca..4070a04 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -78,7 +78,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **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.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)). 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). Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. ### Epic 6 — PvP Security diff --git a/docs/reviews/2026-05-25-NEO-88.md b/docs/reviews/2026-05-25-NEO-88.md index dc450ee..5fdad73 100644 --- a/docs/reviews/2026-05-25-NEO-88.md +++ b/docs/reviews/2026-05-25-NEO-88.md @@ -2,6 +2,8 @@ **Date:** 2026-05-25 **Scope:** Branch `NEO-88-e5m2-server-npc-behavior-catalog-load` · commits `7730bde`–`c56ad22` vs `origin/main` +**Follow-up:** Doc updates per suggestions 1–2 (module register E5.M2 note + E5.M2 freeze Rules paragraph). + **Base:** `origin/main` ## Verdict @@ -18,16 +20,16 @@ NEO-88 delivers **E5M2-02**: fail-fast startup load of `content/npc-behaviors/*_ |----------|--------| | [`docs/plans/NEO-88-implementation-plan.md`](../plans/NEO-88-implementation-plan.md) | **Matches** — kickoff decisions, loader/catalog/DI scope, acceptance checklist, reconciliation section; registry correctly out of scope. | | [`docs/plans/E5M2-prototype-backlog.md`](../plans/E5M2-prototype-backlog.md) · **E5M2-02** | **Matches** — AC checkboxes + landed note. | -| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Partially matches** — Status line cites NEO-88 server load; freeze **Rules** paragraph still links only NEO-87 / `validate_content.py` (E5.M1 cites NEO-77 server startup in the same section). | +| [`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) | **Matches** — Status line cites NEO-88 server load; freeze **Rules** cites CI + server startup (NEO-88). | | [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) · **E5.M2** | **Matches** — NEO-87 + NEO-88 landed notes; next backlog item NEO-89. | -| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E5.M2 **Status** **In Progress** is correct; **E5.M2 note** cites NEO-87 only — add NEO-88 server load (mirror E5.M1 note after NEO-77). | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M2 **Status** **In Progress**; **E5.M2 note** cites NEO-87 + NEO-88 server load. | | [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server startup catalog only; no client mutation or NPC runtime. | | [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — C# loader mirrors `_validate_npc_behavior_catalogs` + E5M2 id/numeric gates; sync comment on `PrototypeE5M2NpcBehaviorCatalogRules.ExpectedBehaviorIds`. | | [`server/README.md`](../../server/README.md) | **Matches** — NPC behavior catalog section (config keys, discovery, fail-fast, E5M2 parity, NEO-89 registry deferral). | | Full-stack epic decomposition | **Matches** — E5M2-02 is **server/content** only; kickoff defers Godot to E5M2-04+ / NEO-98; no false “prototype complete” claim. | | Manual QA | **N/A** — plan documents skip rationale: automated loader/host tests + Bruno health smoke. | -Register/tracking: alignment table E5.M2 **In Progress** with NEO-88 note is correct; module register **E5.M2 note** should mention NEO-88. +Register/tracking: alignment table E5.M2 **In Progress** with NEO-88 note is correct; module register **E5.M2 note** updated with NEO-88. ## Blocking issues @@ -35,9 +37,9 @@ None. ## Suggestions -1. **Update `module_dependency_register.md` E5.M2 note** — Append **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` with link to [NEO-88 plan](../plans/NEO-88-implementation-plan.md) and [server README — NPC behavior catalog](../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88) (same pattern as E5.M1 note after NEO-77). +1. ~~**Update `module_dependency_register.md` E5.M2 note** — Append **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` with link to [NEO-88 plan](../plans/NEO-88-implementation-plan.md) and [server README — NPC behavior catalog](../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88) (same pattern as E5.M1 note after NEO-77).~~ **Done.** E5.M2 note extended in `module_dependency_register.md`. -2. **E5.M2 freeze Rules paragraph** — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend the CI sentence to cite server startup enforcement via NEO-88 (mirror E5.M1 freeze rules referencing NEO-77). +2. ~~**E5.M2 freeze Rules paragraph** — In [`E5_M2_NpcAiAndBehaviorProfiles.md`](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md), extend the CI sentence to cite server startup enforcement via NEO-88 (mirror E5.M1 freeze rules referencing NEO-77).~~ **Done.** Rules paragraph now cites CI + server startup (NEO-88). ## Nits