Merge pull request #125 from ViPro-Technologies/NEO-88-e5m2-server-npc-behavior-catalog-load

NEO-88: E5M2-02 Server NPC behavior catalog load (fail-fast)
pull/129/head
VinPropane 2026-05-25 17:18:42 -04:00 committed by GitHub
commit b611c048c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1086 additions and 8 deletions

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -0,0 +1,140 @@
# 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
- [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
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<NpcBehaviorDefinitionCatalog>()` 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.

View File

@ -0,0 +1,64 @@
# 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`
**Follow-up:** Doc updates per suggestions 12 (module register E5.M2 note + E5.M2 freeze Rules paragraph).
**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) | **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) | **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** updated with 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).~~ **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).~~ **Done.** Rules paragraph now cites CI + server startup (NEO-88).
## 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<ContentPathsOptions>().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.

View File

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

View File

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

View File

@ -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<string>() == 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<NpcBehaviorDefinitionCatalog>();
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<Program>().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);
}
}
}

View File

@ -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<Progra
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.ConfigureAppConfiguration((_, config) =>
{

View File

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

View File

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

View File

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

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Resolves NPC behavior catalog paths for local dev, tests, and container layouts (NEO-88).</summary>
public static class NpcBehaviorCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/npc-behaviors</c> directory.</summary>
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;
}
/// <summary>
/// Resolves the npc-behaviors catalog directory.
/// Empty <paramref name="configuredNpcBehaviorsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
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));
}
/// <summary>Resolves JSON Schema path for a single NPC behavior row (Draft 2020-12).</summary>
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"));
}
}

View File

@ -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;
/// <summary>DI registration for the fail-fast NPC behavior catalog (NEO-88).</summary>
public static class NpcBehaviorCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="NpcBehaviorDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddNpcBehaviorDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<NpcBehaviorDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.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;
}
}

View File

@ -0,0 +1,13 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>One validated <c>NpcBehaviorDef</c> row from <c>content/npc-behaviors/*_npc_behaviors.json</c> (NEO-88).</summary>
public sealed record NpcBehaviorDefRow(
string Id,
string DisplayName,
string ArchetypeKind,
int MaxHp,
double AggroRadius,
double LeashRadius,
double TelegraphWindupSeconds,
int AttackDamage,
double AttackCooldownSeconds);

View File

@ -0,0 +1,25 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>In-memory NPC behavior catalog loaded at startup (NEO-88). Game callers should use INpcBehaviorDefinitionRegistry (NEO-89).</summary>
public sealed class NpcBehaviorDefinitionCatalog(
string npcBehaviorsDirectory,
IReadOnlyDictionary<string, NpcBehaviorDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_npc_behaviors.json</c> catalogs.</summary>
public string NpcBehaviorsDirectory { get; } = npcBehaviorsDirectory;
public IReadOnlyDictionary<string, NpcBehaviorDefRow> ById { get; } =
new ReadOnlyDictionary<string, NpcBehaviorDefRow>(new Dictionary<string, NpcBehaviorDefRow>(byId, StringComparer.Ordinal));
public int DistinctBehaviorCount => ById.Count;
/// <summary>Number of <c>*_npc_behaviors.json</c> files under <see cref="NpcBehaviorsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable behavior <paramref name="id"/>.</summary>
public bool TryGetBehavior(string id, out NpcBehaviorDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -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;
/// <summary>Loads and validates <c>content/npc-behaviors/*_npc_behaviors.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-88).</summary>
public static class NpcBehaviorDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static NpcBehaviorDefinitionCatalog Load(string npcBehaviorsDirectory, string schemaPath, ILogger logger)
{
npcBehaviorsDirectory = Path.GetFullPath(npcBehaviorsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
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<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, NpcBehaviorDefRow>(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<int>(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<string>();
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<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var archetypeKind = (rowObj["archetypeKind"] as JsonValue)!.GetValue<string>();
var maxHp = (rowObj["maxHp"] as JsonValue)!.GetValue<int>();
var aggroRadius = (rowObj["aggroRadius"] as JsonValue)!.GetValue<double>();
var leashRadius = (rowObj["leashRadius"] as JsonValue)!.GetValue<double>();
var telegraphWindupSeconds = (rowObj["telegraphWindupSeconds"] as JsonValue)!.GetValue<double>();
var attackDamage = (rowObj["attackDamage"] as JsonValue)!.GetValue<int>();
var attackCooldownSeconds = (rowObj["attackCooldownSeconds"] as JsonValue)!.GetValue<double>();
return new NpcBehaviorDefRow(
id,
displayName,
archetypeKind,
maxHp,
aggroRadius,
leashRadius,
telegraphWindupSeconds,
attackDamage,
attackCooldownSeconds);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> 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<string> 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());
}
}

View File

@ -0,0 +1,50 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
/// Prototype E5M2 roster + numeric gates (NEO-87 / NEO-88), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS</c>, <c>_prototype_e5m2_npc_behavior_gate</c>, and
/// <c>_prototype_e5m2_npc_behavior_numeric_gate</c>.
/// </summary>
public static class PrototypeE5M2NpcBehaviorCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedBehaviorIds = FrozenSet.ToFrozenSet(
[
"prototype_melee_pressure",
"prototype_ranged_control",
"prototype_elite_mini_boss",
],
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the E5M2 behavior contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE5M2GateError(IReadOnlyDictionary<string, string> 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;
}
/// <summary>Returns a human-readable error when <c>leashRadius &lt;= aggroRadius</c>, otherwise <see langword="null"/>.</summary>
public static string? TryGetNumericGateError(IReadOnlyDictionary<string, NpcBehaviorDefRow> 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;
}
}

View File

@ -88,4 +88,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of abilities directory}/schemas/ability-def.schema.json</c>.
/// </summary>
public string? AbilityDefSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/npc-behaviors</c> directory.
/// </summary>
public string? NpcBehaviorsDirectory { get; set; }
/// <summary>
/// Optional override for <c>npc-behavior-def.schema.json</c>.
/// When unset, resolved as <c>{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json</c>.
/// </summary>
public string? NpcBehaviorDefSchemaPath { get; set; }
}

View File

@ -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<ItemDefinitionCatalog>();
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -15,7 +15,9 @@
"RecipeDefSchemaPath": "",
"RecipeIoRowSchemaPath": "",
"AbilitiesDirectory": "",
"AbilityDefSchemaPath": ""
"AbilityDefSchemaPath": "",
"NpcBehaviorsDirectory": "",
"NpcBehaviorDefSchemaPath": ""
},
"Game": {
"DevPlayerId": "dev-local-1",

View File

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