diff --git a/bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru b/bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru new file mode 100644 index 0000000..757385e --- /dev/null +++ b/bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (ability catalog boot NEO-77) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-77 loads content/abilities/*_abilities.json at startup (fail-fast). No ability 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/ability-catalog/folder.bru b/bruno/neon-sprawl-server/ability-catalog/folder.bru new file mode 100644 index 0000000..b32d530 --- /dev/null +++ b/bruno/neon-sprawl-server/ability-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: ability-catalog +} diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index 8c20037..bf7b722 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M1 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02+ pending (see [dependency register](module_dependency_register.md)) | +| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03+ pending (see [dependency register](module_dependency_register.md)) | | **Linear** | Label **`E5.M1`** · [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-01** [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44) | ## Purpose @@ -80,7 +80,7 @@ The **first shipped four-ability combat spine** under `content/abilities/*.json` | **`prototype_dash`** | Prototype Dash | 0 | 4.0 | `movement` | | **`prototype_burst`** | Prototype Burst | 40 | 5.0 | `attack` | -**Rules:** Do **not** rename these `id` values—change **`displayName`** only, or add a new `id` in a follow-up issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) enforces **exactly** these four ids and duplicate-`id` rejection across all ability JSON files. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows. **`prototype_pulse`** at 25 damage × 4 casts defeats a 100 HP prototype dummy (E5M1 backlog default). Server load: [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) (E5M1-02). +**Rules:** Do **not** rename these `id` values—change **`displayName`** only, or add a new `id` in a follow-up issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) and server startup ([NEO-77](../../plans/NEO-77-implementation-plan.md)) enforce **exactly** these four ids and duplicate-`id` rejection across all ability JSON files. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows. **`prototype_pulse`** at 25 damage × 4 casts defeats a 100 HP prototype dummy (E5M1 backlog default). **`IAbilityDefinitionRegistry`** + hotbar migration: [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) (E5M1-03). ## Prototype notes (NEO-28) diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5a228a2..5894324 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | | 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 | In Progress | **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)). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-02** [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | +| E5.M1 | In Progress | **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). **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-03** [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) through **E5M1-12** [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); gig XP **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32) — **`POST …/ability-cast`** accepts without **`CombatResolution`** yet. | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 1a54104..d31efc4 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned | | E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned | -**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)). +**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)). ### Epic 6 — PvP Security diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index 7e559a6..00795c1 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -115,8 +115,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/ **Acceptance criteria** -- [ ] Host fails startup on invalid ability JSON (mirror CI rules). -- [ ] Tests cover at least one happy path and one malformed catalog rejection. +- [x] Host fails startup on invalid ability JSON (mirror CI rules). +- [x] Tests cover at least one happy path and one malformed catalog rejection. + +**Landed ([NEO-77](https://linear.app/neon-sprawl/issue/NEO-77)):** fail-fast server load of `content/abilities/*_abilities.json` — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77-implementation-plan.md](NEO-77-implementation-plan.md)); [server README — Ability catalog](../../server/README.md#ability-catalog-contentabilities-neo-77). --- diff --git a/docs/plans/NEO-77-implementation-plan.md b/docs/plans/NEO-77-implementation-plan.md new file mode 100644 index 0000000..99b7c6a --- /dev/null +++ b/docs/plans/NEO-77-implementation-plan.md @@ -0,0 +1,139 @@ +# NEO-77 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-77 | +| **Title** | E5M1-02: Server ability catalog load (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-77/e5m1-02-server-ability-catalog-load-fail-fast | +| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-02** | +| **Branch** | `NEO-77-e5m1-server-ability-catalog-load` | +| **Precursor** | [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) — frozen four-ability catalog + CI gates (**Done** on `main`) | +| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) / [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket | +| **Blocks** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` + DI (E5M1-03) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **NEO-77 vs NEO-79 (DI scope)** | Ship `IAbilityDefinitionRegistry` on this story, or only load + `AbilityDefinitionCatalog`? | **Strict split** — NEO-77: loader + `AbilityDefinitionCatalog` + eager fail-fast boot; [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) adds injectable `IAbilityDefinitionRegistry` (E5M1-03). Rationale: E5M1 backlog separates E5M1-02/03; mirrors NEO-66/NEO-67. | **User:** `AbilityDefinitionCatalog` singleton only (kickoff). | +| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` / `RecipeDefinitionCatalogLoaderTests`. | **User:** loader AAA + host boot tests (kickoff). | +| **`PrototypeAbilityRegistry`** | Migrate hotbar/cast allowlist reads this story? | **Leave unchanged** — E5M1-03 explicitly migrates call sites; NEO-77 is load-only. | **User:** unchanged (kickoff). | +| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + E5M1 four-id gate). Rationale: NEO-51 / NEO-66 precedent; no cross-catalog refs in Slice 1. | **Adopted** — NEO-51 precedent; no separate question. | +| **Lookup API on NEO-77** | How does “catalog resolves `AbilityDef` by `id`” land without NEO-79? | **`AbilityDefinitionCatalog.ById`** (read-only map) + `TryGetAbility(string id, out AbilityDefRow row)`; row carries damage + cooldown fields for NEO-79 consumers. | **Adopted** — NEO-66 `RecipeDefinitionCatalog` precedent. | + +## Goal, scope, and out-of-scope + +**Goal:** On host startup, load all `content/abilities/*_abilities.json` catalogs (validated in CI per [NEO-76](NEO-76-implementation-plan.md)), build an in-memory **`AbilityDefinitionCatalog`**, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype E5M1 four-id gate. + +**In scope (from Linear + [E5M1-02](E5M1-prototype-backlog.md#e5m1-02--server-ability-catalog-load-fail-fast)):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Combat/`. +- Configurable abilities directory and schema path (`ContentPathsOptions` extension). +- CI-parity validation: `schemaVersion`, row schema (`ability-def`), duplicate `id`, E5M1 four-id allowlist gate. +- DI registration of **`AbilityDefinitionCatalog`** singleton; eager resolve in `Program.cs`. +- Unit + host startup tests (AAA). +- `server/README.md` section. + +**Out of scope (from Linear):** + +- **`IAbilityDefinitionRegistry`** / hotbar-cast migration from **`PrototypeAbilityRegistry`** (**NEO-79**, E5M1-03). +- HTTP projection (**NEO-78**, E5M1-04), combat resolution (**NEO-81+**). + +## Acceptance criteria checklist + +- [x] Server refuses boot when ability catalog invalid (missing dir, no `*_abilities.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M1 gate failure). +- [x] `AbilityDefinitionCatalog` resolves prototype rows by ability `id` (e.g. `prototype_pulse`, `prototype_burst`) with `baseDamage`, `cooldownSeconds`, optional `abilityKind`. +- [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 **ability count**, **catalog directory**, and **file count** (Information). + +## Implementation reconciliation (shipped) + +- **Loader:** [`AbilityDefinitionCatalogLoader`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs) — CI-parity validation via JsonSchema.Net + E5M1 gate. +- **Catalog:** [`AbilityDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs) + [`AbilityDefRow`](../../server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs) — `TryGetAbility` lookup by id. +- **Gate rules:** [`PrototypeE5M1AbilityCatalogRules`](../../server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs) — sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS`. +- **DI / startup:** [`AbilityCatalogServiceCollectionExtensions`](../../server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs) + eager resolve in [`Program.cs`](../../server/NeonSprawl.Server/Program.cs). +- **Tests:** [`AbilityDefinitionCatalogLoaderTests`](../../server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs) — 14 AAA cases (loader + host). +- **Docs:** `server/README.md` ability catalog section; E5.M1 module doc + alignment register + E5M1 backlog E5M1-02 checkboxes. +- **Unchanged:** [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) — migration deferred to NEO-79. + +## Technical approach + +1. **`Game/Combat/` row DTO** — `AbilityDefRow` (`Id`, `DisplayName`, `BaseDamage`, `CooldownSeconds`, optional `AbilityKind` enum or string). + +2. **`AbilityDefinitionCatalogLoader.Load(abilitiesDirectory, schemaPath, logger)`** — single-pass load mirroring `validate_content.py` `_validate_ability_catalogs` + `_prototype_e5m1_ability_gate`: + - Enumerate `*_abilities.json` (top-level only), ordered by path. + - Build **JsonSchema.Net** evaluator from `ability-def.schema.json`. + - Per file: parse JSON; require `schemaVersion == 1` and top-level `abilities` array. + - Per row: validate against schema; on clean rows track `id`; build `AbilityDefRow`; reject duplicate `id` across files. + - Run **`PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError`** (mirror `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate` in `scripts/validate_content.py`). + - Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Ability catalog validation failed:`. + +3. **`AbilityDefinitionCatalog`** — immutable snapshot: `AbilitiesDirectory`, `ById`, `DistinctAbilityCount`, `CatalogJsonFileCount`; `TryGetAbility(id, out AbilityDefRow?)` for lookups. + +4. **`AbilityCatalogPathResolution`** — `TryDiscoverAbilitiesDirectory`, `ResolveAbilitiesDirectory`, `ResolveAbilityDefSchemaPath` (parent `content/schemas/` layout when unset). + +5. **`PrototypeE5M1AbilityCatalogRules`** — frozen four ability ids; sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate`. Ids: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst` (same set as [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) — unchanged this story). + +6. **`AbilityCatalogServiceCollectionExtensions.AddAbilityDefinitionCatalog`** — bind `ContentPathsOptions`; register **`AbilityDefinitionCatalog`** singleton factory that resolves paths and calls loader. No dependency on item/skill catalogs (Slice 1 abilities have no cross-ref fields). + +7. **`Program.cs`** — `AddAbilityDefinitionCatalog(configuration)` alongside existing catalog registrations; after `Build()`, `GetRequiredService()` with other eager catalog resolves. + +8. **Tests** — temp-dir fixtures copying repo `ability-def.schema.json`; valid four-ability JSON matching [prototype_abilities.json](../../content/abilities/prototype_abilities.json); negative cases (duplicate id, wrong/missing E5M1 roster, bad `baseDamage` / `cooldownSeconds`, empty `displayName`, `schemaVersion` ≠ 1, missing dir/schema, no `*_abilities.json`, invalid JSON); `InMemoryWebApplicationFactory` host test + `WebApplicationFactory` with empty abilities dir fails startup. + +9. **Test factories** — pin `Content:AbilitiesDirectory` in `InMemoryWebApplicationFactory` and other `WebApplicationFactory` subclasses that already pin recipes/items (same pattern as NEO-66). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs` | Load-time `AbilityDef` row DTO. | +| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs` | In-memory catalog + `TryGetAbility`. | +| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs` | Disk I/O, schema validation, E5M1 gate. | +| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs` | Directory/schema discovery and config resolution. | +| `server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs` | Frozen four ids gate (sync comment to Python). | +| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | `AddAbilityDefinitionCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs` | Repo `content/abilities` + schema discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. | +| `docs/plans/NEO-77-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `AbilitiesDirectory`, `AbilityDefSchemaPath` under `Content` section. | +| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `AbilityDefinitionCatalog` at startup. | +| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:AbilitiesDirectory` / schema path keys for override documentation (mirror other catalogs). | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:AbilitiesDirectory` to discovered repo path so existing tests keep booting. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Pin abilities directory (same as recipes/items). | +| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. | +| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. | +| `server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. | +| `server/README.md` | New **Ability catalog (`content/abilities`, NEO-77)** section (config keys, discovery, fail-fast, E5M1 parity with CI). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — note NEO-77 server load when implementation completes. | +| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Status line — E5M1-02 server load landed when complete. | +| `docs/plans/E5M1-prototype-backlog.md` | E5M1-02 checkboxes when implementation completes. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs` | **Unit:** valid four-ability prototype catalog loads; `abilities` not array; schema violation (negative `baseDamage`, zero `cooldownSeconds`, empty `displayName`); duplicate `id` across files; E5M1 gate failure (missing id + extra/wrong id); `schemaVersion` ≠ 1; missing dir/schema; no `*_abilities.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `AbilityDefinitionCatalog` count 4; `WebApplicationFactory` with empty abilities 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 `PrototypeE5M1AbilityCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate`. | **adopted** | +| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:AbilitiesDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** | +| **Registry consumers** | Hotbar/cast/combat use `IAbilityDefinitionRegistry` in NEO-79+; this story only exposes `AbilityDefinitionCatalog` for boot + direct test lookup. **`PrototypeAbilityRegistry` unchanged.** | **adopted** | +| **Global cooldown still active** | Expected — catalog lands before E5M1-07 wires per-ability `cooldownSeconds`; load does not change runtime cast behavior. | **adopted** | + +## Decisions (kickoff) + +- **`AbilityDefinitionCatalog` singleton only** — defer `IAbilityDefinitionRegistry` and `PrototypeAbilityRegistry` migration to NEO-79 (user confirmed kickoff). +- **In-process validation** + **loader and host boot tests** per NEO-51 / NEO-66 precedent. +- **No cross-catalog checks** — ability rows have no item/skill refs in Slice 1; loader has no catalog dependencies. diff --git a/docs/reviews/2026-05-24-NEO-77.md b/docs/reviews/2026-05-24-NEO-77.md new file mode 100644 index 0000000..e31ccf2 --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-77.md @@ -0,0 +1,66 @@ +# Code review — NEO-77 server ability catalog load (fail-fast) + +**Date:** 2026-05-24 +**Scope:** Branch `NEO-77-e5m1-server-ability-catalog-load` · commits `07444e4`–`7c2697e` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-77 delivers **E5M1-02**: fail-fast startup load of `content/abilities/*_abilities.json` under `server/NeonSprawl.Server/Game/Combat/`, mirroring the NEO-51 / NEO-66 catalog pattern. The loader validates Draft 2020-12 row schema, `schemaVersion`, duplicate `id`, and the frozen E5M1 four-id gate (`PrototypeE5M1AbilityCatalogRules`), registers `AbilityDefinitionCatalog` in DI, and eagerly resolves it in `Program.cs`. Fourteen AAA loader/host tests pass; full suite (354 tests) is green. Docs (plan, E5M1 backlog, E5.M1 module page, alignment register, `server/README.md`) are updated. `PrototypeAbilityRegistry` is correctly unchanged (NEO-79). Bruno adds a health smoke request consistent with other catalog stories. No client or ability HTTP API — appropriately deferred. Risk is low: infrastructure-only, CI already gates content. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-77-implementation-plan.md`](../plans/NEO-77-implementation-plan.md) | **Matches** — kickoff decisions, loader/catalog/DI scope, acceptance checklist, reconciliation section; `IAbilityDefinitionRegistry` correctly out of scope. | +| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-02** | **Matches** — AC checkboxes + landed note. | +| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status line and freeze rules reference NEO-77 server load + NEO-79 registry deferral. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row updated with NEO-77 landed note; next backlog item NEO-79. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 table **Status** **In Progress** is correct; **E5.M1 note** cites NEO-76 + NEO-77 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 combat resolution. | +| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — C# loader mirrors `_validate_ability_catalogs` + `_prototype_e5m1_ability_gate`; sync comment on `PrototypeE5M1AbilityCatalogRules.ExpectedAbilityIds`. | +| [`server/README.md`](../../server/README.md) | **Matches** — Ability catalog section (config keys, discovery, fail-fast, E5M1 parity). | +| Full-stack epic decomposition | **Matches** — E5M1-02 is **server/content** only; client counterparts documented in backlog (NEO-85 / NEO-86); 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.M1 **In Progress** with NEO-77 note is correct. + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Add `folder.bru` under `bruno/neon-sprawl-server/ability-catalog/`** — Other catalog smoke folders (`item-catalog`, `recipe-catalog`, `resource-node-catalog`, `mastery-catalog`) include a `folder.bru` meta file; ability-catalog currently has only the health request. Add for Bruno collection parity.~~ **Done.** Added `bruno/neon-sprawl-server/ability-catalog/folder.bru`. + +2. ~~**E5M1 gate test — extra/wrong id** — `Load_ShouldThrow_WhenE5M1GateFails` removes one ability (missing id). Plan test table also lists wrong roster; consider an AAA case that swaps in an unknown id (e.g. `prototype_extra`) to cover the “got […]” branch, not only “missing”.~~ **Done.** Renamed missing-id test to `Load_ShouldThrow_WhenE5M1GateFailsWithMissingId`; added `Load_ShouldThrow_WhenE5M1GateFailsWithExtraId`. Plan test table updated. + +## Nits + +- Nit: `ValidPrototypeCatalogJson` duplicates `content/abilities/prototype_abilities.json` inline — fine for isolated temp-dir fixtures (same pattern as NEO-66 recipe tests); optional future refactor to copy repo file. + +- Nit: E5M1 gate error string format differs slightly from Python (`'prototype_pulse', …` 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); harmless duplicate binds. + +## Verification + +```bash +cd /home/don/neon-sprawl/server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~AbilityDefinitionCatalogLoaderTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj + +# Content CI parity (repo root) +python3 scripts/validate_content.py +# Expect: 4 unique ability id(s) +``` + +Manual (optional): + +- Start server from repo root; confirm Information log: ability count **4**, catalog directory, file count **1**. +- Bruno: `bruno/neon-sprawl-server/ability-catalog/Health after ability catalog load.bru` → 200 + `service: NeonSprawl.Server`. +- Negative: set `Content__AbilitiesDirectory` to an empty temp dir → startup fails with `Ability catalog validation failed:`. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs new file mode 100644 index 0000000..1965b73 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs @@ -0,0 +1,17 @@ +using NeonSprawl.Server.Game.Combat; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +internal static class AbilityCatalogTestPaths +{ + internal static string DiscoverRepoAbilitiesDirectory() => + AbilityCatalogPathResolution.TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/abilities from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoAbilityDefSchemaPath() => + AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath( + DiscoverRepoAbilitiesDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..74041ee --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs @@ -0,0 +1,350 @@ +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.Combat; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Combat; + +public class AbilityDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_pulse", + "displayName": "Prototype Pulse", + "baseDamage": 25, + "cooldownSeconds": 3.0, + "abilityKind": "attack" + }, + { + "id": "prototype_guard", + "displayName": "Prototype Guard", + "baseDamage": 0, + "cooldownSeconds": 6.0, + "abilityKind": "utility" + }, + { + "id": "prototype_dash", + "displayName": "Prototype Dash", + "baseDamage": 0, + "cooldownSeconds": 4.0, + "abilityKind": "movement" + }, + { + "id": "prototype_burst", + "displayName": "Prototype Burst", + "baseDamage": 40, + "cooldownSeconds": 5.0, + "abilityKind": "attack" + } + ] + } + """; + + private static (string Root, string AbilitiesDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-abilitycat-"); + var abilitiesDir = Path.Combine(root.FullName, "content", "abilities"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(abilitiesDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "ability-def.schema.json"); + File.Copy(AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, abilitiesDir, schemaPath); + } + + private static void WriteCatalog(string abilitiesDir, string catalogJson) => + File.WriteAllText(Path.Combine(abilitiesDir, "prototype_abilities.json"), catalogJson, Encoding.UTF8); + + private static JsonObject GetAbilityRow(JsonObject catalogRoot, string abilityId) + { + var abilities = catalogRoot["abilities"] as JsonArray + ?? throw new InvalidOperationException("expected abilities array"); + foreach (var node in abilities) + { + if (node is JsonObject row && row["id"]?.GetValue() == abilityId) + return row; + } + + throw new InvalidOperationException($"ability id not found: {abilityId}"); + } + + private static AbilityDefinitionCatalog LoadCatalog(string abilitiesDir, string schemaPath) => + AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(abilitiesDir, schemaPath); + // Assert + Assert.Equal(4, catalog.DistinctAbilityCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetAbility("prototype_pulse", out var pulse)); + Assert.NotNull(pulse); + Assert.Equal(25, pulse!.BaseDamage); + Assert.Equal(3.0, pulse.CooldownSeconds); + Assert.Equal("attack", pulse.AbilityKind); + } + + [Fact] + public void Load_ShouldThrow_WhenAbilitiesIsNotArray() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(abilitiesDir, "bad_abilities.json"), + """{"schemaVersion": 1, "abilities": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'abilities' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(abilitiesDir, "bad_abilities.json"), + """{"schemaVersion": 2, "abilities": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateAbilityIdAcrossFiles() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(abilitiesDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(abilitiesDir, "extra_abilities.json"), + """ + { + "schemaVersion": 1, + "abilities": [ + { + "id": "prototype_pulse", + "displayName": "Duplicate Pulse", + "baseDamage": 1, + "cooldownSeconds": 1.0 + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate ability id 'prototype_pulse'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M1GateFailsWithMissingId() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + var abilities = root["abilities"] as JsonArray + ?? throw new InvalidOperationException("expected abilities array"); + abilities.RemoveAt(abilities.Count - 1); + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M1GateFailsWithExtraId() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_burst")["id"] = "prototype_extra"; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M1 expects exactly ability ids", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype_extra", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenBaseDamageIsNegative() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_pulse")["baseDamage"] = -1; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("baseDamage", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenCooldownSecondsIsZero() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_pulse")["cooldownSeconds"] = 0; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("cooldownSeconds", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDisplayNameIsEmpty() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetAbilityRow(root, "prototype_pulse")["displayName"] = ""; + WriteCatalog(abilitiesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Ability catalog validation failed", ioe.Message, StringComparison.Ordinal); + Assert.Contains("displayName", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenNoAbilityCatalogFiles() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("no *_abilities.json files", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenJsonIsInvalid() + { + // Arrange + var (_, abilitiesDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(abilitiesDir, "bad_abilities.json"), + "{not json", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(abilitiesDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_abilities.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenAbilitiesDirectoryMissing() + { + // Arrange + var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-abilities-" + Guid.NewGuid().ToString("n")); + var schemaPath = AbilityCatalogTestPaths.DiscoverRepoAbilityDefSchemaPath(); + // Act + var ex = Record.Exception(() => + AbilityDefinitionCatalogLoader.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 (_, abilitiesDir, _) = CreateTempContentLayout(); + var missingSchema = Path.Combine(abilitiesDir, "missing-ability-def.schema.json"); + // Act + var ex = Record.Exception(() => + AbilityDefinitionCatalogLoader.Load(abilitiesDir, 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(4, catalog.DistinctAbilityCount); + Assert.True(catalog.TryGetAbility("prototype_burst", out var burst)); + Assert.Equal(40, burst!.BaseDamage); + Assert.Equal(5.0, burst.CooldownSeconds); + } + + [Fact] + public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-abilities-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:AbilitiesDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Ability catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index f9efdbe..c1f96b7 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; @@ -41,11 +42,15 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + 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."); 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("Game:EnableMasteryFixtureApi", "true"); builder.ConfigureTestServices(services => diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 0337336..dd6072b 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; @@ -39,11 +40,15 @@ 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 1e29349..fb336d1 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -33,9 +34,13 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + 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."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); + builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs index ea04cc8..b0b9a23 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.PositionState; @@ -33,9 +34,13 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory) ?? throw new InvalidOperationException( "Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + 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."); builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:RecipesDirectory", recipesDir); + builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir); builder.ConfigureTestServices(services => { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 0afbe68..dc283ad 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; @@ -42,11 +43,15 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs new file mode 100644 index 0000000..cf75380 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// Resolves ability catalog paths for local dev, tests, and container layouts (NEO-77). +public static class AbilityCatalogPathResolution +{ + /// Walks and parents for an existing content/abilities directory. + public static string? TryDiscoverAbilitiesDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "abilities"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the abilities catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveAbilitiesDirectory(string? configuredAbilitiesDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredAbilitiesDirectory)) + { + var discovered = TryDiscoverAbilitiesDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:AbilitiesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/abilities'). " + + "Set Content:AbilitiesDirectory in configuration or environment (e.g. Content__AbilitiesDirectory)."); + } + + var trimmed = configuredAbilitiesDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single ability row (Draft 2020-12). + public static string ResolveAbilityDefSchemaPath( + string abilitiesDirectory, + 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(abilitiesDirectory, "..", "schemas", "ability-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..68e7c9f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.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.Combat; + +/// DI registration for the fail-fast ability catalog (NEO-77). +public static class AbilityCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddAbilityDefinitionCatalog(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.Combat.AbilityCatalog"); + + var abilitiesDir = AbilityCatalogPathResolution.ResolveAbilitiesDirectory( + opts.AbilitiesDirectory, + hostEnv.ContentRootPath); + var schemaPath = AbilityCatalogPathResolution.ResolveAbilityDefSchemaPath( + abilitiesDir, + opts.AbilityDefSchemaPath, + hostEnv.ContentRootPath); + + return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs new file mode 100644 index 0000000..34ff4cb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs @@ -0,0 +1,9 @@ +namespace NeonSprawl.Server.Game.Combat; + +/// One validated AbilityDef row from content/abilities/*_abilities.json (NEO-77). +public sealed record AbilityDefRow( + string Id, + string DisplayName, + int BaseDamage, + double CooldownSeconds, + string? AbilityKind); diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs new file mode 100644 index 0000000..1c5b10f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs @@ -0,0 +1,24 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Combat; + +/// In-memory ability catalog loaded at startup (NEO-77). Game callers should use IAbilityDefinitionRegistry (NEO-79). +public sealed class AbilityDefinitionCatalog( + string abilitiesDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_abilities.json catalogs. + public string AbilitiesDirectory { get; } = abilitiesDirectory; + + public IReadOnlyDictionary ById { get; } = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctAbilityCount => ById.Count; + + /// Number of *_abilities.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable ability . + public bool TryGetAbility(string id, out AbilityDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs new file mode 100644 index 0000000..5eb5e83 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs @@ -0,0 +1,190 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Combat; + +/// Loads and validates content/abilities/*_abilities.json using the same rules as scripts/validate_content.py (NEO-77). +public static class AbilityDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static AbilityDefinitionCatalog Load(string abilitiesDirectory, string schemaPath, ILogger logger) + { + abilitiesDirectory = Path.GetFullPath(abilitiesDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + if (!File.Exists(schemaPath)) + errors.Add($"error: missing schema file {schemaPath}"); + + if (!Directory.Exists(abilitiesDirectory)) + errors.Add($"error: missing directory {abilitiesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(abilitiesDirectory, "*_abilities.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_abilities.json files under {abilitiesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(schemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var abilityIdToSourceFile = 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 abilitiesNode = rootObj["abilities"]; + if (abilitiesNode is not JsonArray abilitiesArray) + { + errors.Add($"error: {path}: expected top-level 'abilities' array"); + continue; + } + + for (var i = 0; i < abilitiesArray.Count; i++) + { + var ability = abilitiesArray[i]; + if (ability is not JsonObject rowObj) + { + errors.Add($"error: {path}: abilities[{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} abilities[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var aid = (rowObj["id"] as JsonValue)?.GetValue(); + if (aid is not null && rowSchemaErrors == 0) + { + if (abilityIdToSourceFile.TryGetValue(aid, out var prevPath)) + { + errors.Add($"error: duplicate ability id '{aid}' in {prevPath} and {path}"); + continue; + } + + abilityIdToSourceFile[aid] = path; + rows[aid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var e5m1 = PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError(abilityIdToSourceFile); + if (e5m1 is not null) + { + errors.Add(e5m1); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded ability catalog from {AbilitiesDirectory}: {AbilityCount} ability(s) across {CatalogFileCount} JSON catalog file(s).", + abilitiesDirectory, + rows.Count, + jsonFiles.Length); + } + + return new AbilityDefinitionCatalog(abilitiesDirectory, rows, jsonFiles.Length); + } + + private static AbilityDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue(); + var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue(); + string? abilityKind = null; + if (rowObj["abilityKind"] is JsonValue abilityKindValue) + abilityKind = abilityKindValue.GetValue(); + + return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind); + } + + 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} abilities[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Ability 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/Combat/PrototypeE5M1AbilityCatalogRules.cs b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs new file mode 100644 index 0000000..30f8b89 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs @@ -0,0 +1,35 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from scripts/validate_content.py +/// PROTOTYPE_E5M1_ABILITY_IDS / _prototype_e5m1_ability_gate. +/// +public static class PrototypeE5M1AbilityCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M1_ABILITY_IDS. + public static readonly FrozenSet ExpectedAbilityIds = FrozenSet.ToFrozenSet( + [ + "prototype_pulse", + "prototype_guard", + "prototype_dash", + "prototype_burst", + ], + StringComparer.Ordinal); + + /// Returns a human-readable error if the E5M1 ability contract fails, otherwise . + public static string? TryGetE5M1GateError(IReadOnlyDictionary abilityIdToSourceFile) + { + var ids = abilityIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedAbilityIds)) + { + return + "error: prototype E5M1 expects exactly ability ids " + + $"[{string.Join(", ", ExpectedAbilityIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 4655375..93da821 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -76,4 +76,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of recipes directory}/schemas/recipe-io-row.schema.json. /// public string? RecipeIoRowSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/abilities directory. + /// + public string? AbilitiesDirectory { get; set; } + + /// + /// Optional override for ability-def.schema.json. + /// When unset, resolved as {parent of abilities directory}/schemas/ability-def.schema.json. + /// + public string? AbilityDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 645dd15..87ae584 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Interaction; @@ -19,6 +20,7 @@ builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); +builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); @@ -26,6 +28,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 3e9cd5a..b13ffb7 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -13,7 +13,9 @@ "ItemDefSchemaPath": "", "RecipesDirectory": "", "RecipeDefSchemaPath": "", - "RecipeIoRowSchemaPath": "" + "RecipeIoRowSchemaPath": "", + "AbilitiesDirectory": "", + "AbilityDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index d8321d1..0277adf 100644 --- a/server/README.md +++ b/server/README.md @@ -80,6 +80,19 @@ On startup the host loads every **`*_recipes.json`** under the recipes directory On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code. +## Ability catalog (`content/abilities`, NEO-77) + +On startup the host loads every **`*_abilities.json`** under the abilities directory, validates each row against **`content/schemas/ability-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype E5M1** four-id roster gate (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:AbilitiesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/abilities`**. | +| **`Content:AbilityDefSchemaPath`** | Optional override for **`ability-def.schema.json`**. When unset, **`{parent of abilities directory}/schemas/ability-def.schema.json`**. | + +**Docker / CI:** include **`content/abilities`** and **`content/schemas/ability-def.schema.json`** in the mounted **`content/`** tree; set **`Content__AbilitiesDirectory`** when layout differs. + +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; **`PrototypeAbilityRegistry`** hotbar/cast allowlist is unchanged until NEO-79. + ## Recipe definitions (NEO-68) **`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.