Merge pull request #111 from ViPro-Technologies/NEO-77-e5m1-server-ability-catalog-load
NEO-77: E5M1-02 server ability catalog load (fail-fast)pull/113/head
commit
91fab3e398
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: ability-catalog
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AbilityDefinitionCatalog>()` 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.
|
||||
|
|
@ -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<ContentPathsOptions>().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:`.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<string>() == 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<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 (_, 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<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<AbilityDefinitionCatalog>();
|
||||
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<Program>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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<Progra
|
|||
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.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<Progra
|
|||
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 =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>Resolves ability catalog paths for local dev, tests, and container layouts (NEO-77).</summary>
|
||||
public static class AbilityCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/abilities</c> directory.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the abilities catalog directory.
|
||||
/// Empty <paramref name="configuredAbilitiesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single ability row (Draft 2020-12).</summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>DI registration for the fail-fast ability catalog (NEO-77).</summary>
|
||||
public static class AbilityCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="AbilityDefinitionCatalog"/> as a singleton.</summary>
|
||||
public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<AbilityDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>One validated <c>AbilityDef</c> row from <c>content/abilities/*_abilities.json</c> (NEO-77).</summary>
|
||||
public sealed record AbilityDefRow(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
int BaseDamage,
|
||||
double CooldownSeconds,
|
||||
string? AbilityKind);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>In-memory ability catalog loaded at startup (NEO-77). Game callers should use <c>IAbilityDefinitionRegistry</c> (NEO-79).</summary>
|
||||
public sealed class AbilityDefinitionCatalog(
|
||||
string abilitiesDirectory,
|
||||
IReadOnlyDictionary<string, AbilityDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_abilities.json</c> catalogs.</summary>
|
||||
public string AbilitiesDirectory { get; } = abilitiesDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, AbilityDefRow> ById { get; } = new ReadOnlyDictionary<string, AbilityDefRow>(new Dictionary<string, AbilityDefRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctAbilityCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_abilities.json</c> files under <see cref="AbilitiesDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
|
||||
/// <summary>Resolves a catalog row by stable ability <paramref name="id"/>.</summary>
|
||||
public bool TryGetAbility(string id, out AbilityDefRow? row) =>
|
||||
ById.TryGetValue(id, out row);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>Loads and validates <c>content/abilities/*_abilities.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-77).</summary>
|
||||
public static class AbilityDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static AbilityDefinitionCatalog Load(string abilitiesDirectory, string schemaPath, ILogger logger)
|
||||
{
|
||||
abilitiesDirectory = Path.GetFullPath(abilitiesDirectory);
|
||||
schemaPath = Path.GetFullPath(schemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
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<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, AbilityDefRow>(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 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<string>();
|
||||
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<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var baseDamage = (rowObj["baseDamage"] as JsonValue)!.GetValue<int>();
|
||||
var cooldownSeconds = (rowObj["cooldownSeconds"] as JsonValue)!.GetValue<double>();
|
||||
string? abilityKind = null;
|
||||
if (rowObj["abilityKind"] is JsonValue abilityKindValue)
|
||||
abilityKind = abilityKindValue.GetValue<string>();
|
||||
|
||||
return new AbilityDefRow(id, displayName, baseDamage, cooldownSeconds, abilityKind);
|
||||
}
|
||||
|
||||
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} abilities[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype E5M1 roster gate (NEO-76 / NEO-77), mirrored from <c>scripts/validate_content.py</c>
|
||||
/// <c>PROTOTYPE_E5M1_ABILITY_IDS</c> / <c>_prototype_e5m1_ability_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeE5M1AbilityCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M1_ABILITY_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedAbilityIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"prototype_pulse",
|
||||
"prototype_guard",
|
||||
"prototype_dash",
|
||||
"prototype_burst",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the E5M1 ability contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetE5M1GateError(IReadOnlyDictionary<string, string> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -76,4 +76,16 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-io-row.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? RecipeIoRowSchemaPath { 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/abilities</c> directory.
|
||||
/// </summary>
|
||||
public string? AbilitiesDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>ability-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of abilities directory}/schemas/ability-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? AbilityDefSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SkillDefinitionCatalog>();
|
|||
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@
|
|||
"ItemDefSchemaPath": "",
|
||||
"RecipesDirectory": "",
|
||||
"RecipeDefSchemaPath": "",
|
||||
"RecipeIoRowSchemaPath": ""
|
||||
"RecipeIoRowSchemaPath": "",
|
||||
"AbilitiesDirectory": "",
|
||||
"AbilityDefSchemaPath": ""
|
||||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue