Merge pull request #112 from ViPro-Technologies/NEO-79-iabilitydefinitionregistry-di

NEO-79: IAbilityDefinitionRegistry + DI and hotbar/cast migration
pull/113/head
VinPropane 2026-05-24 22:17:30 -04:00 committed by GitHub
commit 83d1dde4ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 697 additions and 32 deletions

View File

@ -0,0 +1,51 @@
meta {
name: POST cast unknown ability deny
type: http
seq: 9
}
docs {
NEO-79: unknown ability ids deny with reasonCode unknown_ability (catalog-backed IAbilityDefinitionRegistry).
Pre-request binds slot 0 so cast validation reaches ability allowlist (not slot_unbound).
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId") || "dev-local-1";
await axios.post(
`${baseUrl}/game/players/${playerId}/hotbar-loadout`,
{
schemaVersion: 1,
slots: [{ slotIndex: 0, abilityId: "prototype_pulse" }],
},
{ headers: { "Content-Type": "application/json" } },
);
}
post {
url: {{baseUrl}}/game/players/dev-local-1/ability-cast
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slotIndex": 0,
"abilityId": "not_a_real_ability",
"targetId": null
}
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("accepted false with unknown_ability", function () {
const body = res.getBody();
expect(body.accepted).to.equal(false);
expect(body.reasonCode).to.equal("unknown_ability");
});
}

View File

@ -0,0 +1,39 @@
meta {
name: POST hotbar loadout unknown ability deny
type: http
seq: 5
}
docs {
NEO-79: unknown ability ids deny with reasonCode unknown_ability (catalog-backed IAbilityDefinitionRegistry).
}
post {
url: {{baseUrl}}/game/players/dev-local-1/hotbar-loadout
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"slots": [
{
"slotIndex": 2,
"abilityId": "missing_ability"
}
]
}
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("updated false with unknown_ability", function () {
const body = res.getBody();
expect(body.updated).to.equal(false);
expect(body.reasonCode).to.equal("unknown_ability");
});
}

View File

@ -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 [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) server load landed; E5M1-03+ 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 [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) registry + hotbar/cast migration landed; E5M1-04+ 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)) 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).
**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/cast migration **landed** ([NEO-79](../../plans/NEO-79-implementation-plan.md), E5M1-03).
## Prototype notes (NEO-28)

View File

@ -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-57NEO-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)). **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) |
| 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). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **Backlog decomposed:** Epic 5 Slice 1 — [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md) **E5M1-04** [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) 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), [NEO-79](../../plans/NEO-79-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) |
---

View File

@ -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)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` ([NEO-77 plan](../../plans/NEO-77-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)). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79 plan](../../plans/NEO-79-implementation-plan.md)).
### Epic 6 — PvP Security

View File

@ -139,8 +139,10 @@ Working backlog for **Epic 5 — Slice 1** ([combat rules MVP](../decomposition/
**Acceptance criteria**
- [ ] Unknown ability id fails hotbar/cast validation same as today.
- [ ] All four prototype ids resolve with expected **`baseDamage`** / **`cooldownSeconds`**.
- [x] Unknown ability id fails hotbar/cast validation same as today.
- [x] All four prototype ids resolve with expected **`baseDamage`** / **`cooldownSeconds`**.
**Landed ([NEO-79](https://linear.app/neon-sprawl/issue/NEO-79)):** [`IAbilityDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs) + DI; hotbar/cast migrated — [NEO-79-implementation-plan.md](NEO-79-implementation-plan.md).
---

View File

@ -0,0 +1,127 @@
# NEO-79 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-79 |
| **Title** | E5M1-03: `IAbilityDefinitionRegistry` + DI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-79/e5m1-03-iabilitydefinitionregistry-di |
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-03** |
| **Branch** | `NEO-79-iabilitydefinitionregistry-di` |
| **Precursor** | [NEO-77](https://linear.app/neon-sprawl/issue/NEO-77) — fail-fast `AbilityDefinitionCatalog` load (**Done** on `main`) |
| **Pattern** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) / [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) — thin registry adapter over startup catalog + DI; strict split after NEO-77 loader |
| **Blocks** | [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) — GET world ability-definitions; [NEO-80+](E5M1-prototype-backlog.md) — combat engine consumers |
| **Client counterpart** | None (server-only); optional client enrichment in [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) via NEO-78 HTTP |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Enumeration API** | Include `GetDefinitionsInIdOrder()` now? | **Yes** — mirror `IRecipeDefinitionRegistry`; [NEO-78](https://linear.app/neon-sprawl/issue/NEO-78) `GET /game/world/ability-definitions` needs ordered defs without reaching into `AbilityDefinitionCatalog`. | **User:** include enumeration. |
| **Lookup naming** | `TryGetDefinition` vs `TryGetAbility`? | **`TryGetDefinition(string? abilityId, …)`** — same naming as item/skill/recipe registries; catalog keeps `TryGetAbility` for direct catalog access. | **User:** `TryGetDefinition`. |
| **Hotbar/cast migration** | How replace `PrototypeAbilityRegistry.TryNormalizeKnown`? | **`TryNormalizeKnown` on `IAbilityDefinitionRegistry`** — trim + lowercase + catalog lookup; inject into hotbar/cast APIs. Preserves todays deny behavior (`unknown_ability`) without duplicating normalize in two endpoints. | **User:** registry `TryNormalizeKnown`. |
| **`PrototypeAbilityRegistry`** | Keep static allowlist or slim down? | **Keep id constant strings only** — remove `HashSet` allowlist and `TryNormalizeKnown`; tests/fixtures keep `PrototypePulse` etc. | **User:** constants only. |
| **Program.cs eager resolve** | Eager-resolve `IAbilityDefinitionRegistry` at boot? | **Omit**`AbilityDefinitionCatalog` is already eager-resolved in `Program.cs` (NEO-77); registry is a thin adapter (NEO-67 default). | **Adopted** — NEO-67 precedent; no separate question. |
| **Runtime validation** | Re-validate abilities in registry? | **No** — catalog load is fail-fast (NEO-77); registry delegates to loaded rows only. | **Adopted** — NEO-67 precedent. |
## Goal, scope, and out-of-scope
**Goal:** Provide **`IAbilityDefinitionRegistry`** backed by the startup-loaded **`AbilityDefinitionCatalog`**: resolve by stable **`abilityId`**, enumerate definitions in id order, and expose **`TryNormalizeKnown`** for hotbar/cast allowlist validation. Register in DI and migrate **`HotbarLoadoutApi`** / **`AbilityCastApi`** off the static allowlist so unknown ids deny exactly as today while known ids resolve **`baseDamage`** / **`cooldownSeconds`** from content.
**In scope (from Linear + [E5M1-03](E5M1-prototype-backlog.md#e5m1-03--iabilitydefinitionregistry--di)):**
- `IAbilityDefinitionRegistry` + `AbilityDefinitionRegistry` thin adapter over `AbilityDefinitionCatalog`.
- DI registration in `AddAbilityDefinitionCatalog`.
- Unit tests (AAA): known-id lookup with damage + cooldown; unknown-id miss; `TryNormalizeKnown` trim/case behavior; enumeration order; host resolves registry from DI.
- Migrate hotbar/cast call sites from `PrototypeAbilityRegistry.TryNormalizeKnown` to injected registry.
- Slim `PrototypeAbilityRegistry` to id constants only.
**Out of scope (from Linear + backlog):**
- HTTP GET route ([NEO-78](https://linear.app/neon-sprawl/issue/NEO-78)).
- Damage application, combat engine, cooldown commit from catalog ([NEO-80+](E5M1-prototype-backlog.md)).
- Changing loader, E5M1 gate, or catalog load semantics (NEO-77).
- Godot / client changes.
## Acceptance criteria checklist
- [x] Unknown ability id fails hotbar/cast validation same as today (`unknown_ability`, no behavior regression).
- [x] All four prototype ids resolve with expected **`baseDamage`** / **`cooldownSeconds`** via `TryGetDefinition`.
- [x] DI resolves `IAbilityDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
- [x] Unit tests (AAA): lookup for known prototype ids; unknown id returns false without throwing; `TryNormalizeKnown` accepts trimmed/case-variant input for catalog ids; `GetDefinitionsInIdOrder` returns four rows ordered by `id`.
## Technical approach
1. **`IAbilityDefinitionRegistry`** — mirror [`IRecipeDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Crafting/IRecipeDefinitionRegistry.cs):
- `TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition)` — null and unknown ids return false without throwing; exact ordinal match on catalog keys.
- `TryNormalizeKnown(string rawAbilityId, [NotNullWhen(true)] out string normalized)` — trim, lowercase, empty/whitespace → false; success when normalized id exists in catalog (replaces static allowlist semantics).
- `GetDefinitionsInIdOrder()` — all rows ordered by `AbilityDefRow.Id` (ordinal).
- XML remarks: combat engine ([NEO-81+](../../docs/plans/E5M1-prototype-backlog.md)) and HTTP read model ([NEO-78](https://linear.app/neon-sprawl/issue/NEO-78)) should depend on this interface, not `AbilityDefinitionCatalog`.
2. **`AbilityDefinitionRegistry`** — primary-constructor adapter over `AbilityDefinitionCatalog` (same pattern as [`RecipeDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionRegistry.cs)); precompute ordered list at construction.
3. **DI** — extend [`AddAbilityDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs) to register `IAbilityDefinitionRegistry``AbilityDefinitionRegistry` after catalog singleton. No change to `Program.cs` eager-resolve (catalog only).
4. **Hotbar/cast migration** — inject `IAbilityDefinitionRegistry` into:
- [`HotbarLoadoutApi`](../../server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs) POST handler — replace `PrototypeAbilityRegistry.TryNormalizeKnown` with `registry.TryNormalizeKnown`.
- [`AbilityCastApi`](../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) POST handler — same replacement for request `abilityId` validation.
5. **`PrototypeAbilityRegistry`** — retain only public const id strings (`PrototypePulse`, etc.); remove static `HashSet` and `TryNormalizeKnown`. Update class summary to point at `IAbilityDefinitionRegistry`.
6. **Docs** — update [`server/README.md`](../../server/README.md) ability-catalog section (registry is preferred lookup; hotbar/cast migrated). Optional one-line E5M1 backlog note when implementation lands.
7. **Tests** — new `AbilityDefinitionRegistryTests.cs` mirroring `RecipeDefinitionRegistryTests`: in-memory catalog helper, loader-backed prototype fixture via `AbilityCatalogTestPaths` + repo `prototype_abilities.json`, host DI test asserting four prototype rows with expected damage/cooldown from [E5.M1 freeze box](../decomposition/modules/E5_M1_CombatRulesEngine.md). Existing hotbar/cast API tests should remain green without changing expected deny/accept behavior.
### Expected prototype lookup values (from content)
| `id` | `baseDamage` | `cooldownSeconds` |
|------|--------------|-------------------|
| `prototype_pulse` | 25 | 3.0 |
| `prototype_guard` | 0 | 6.0 |
| `prototype_dash` | 0 | 4.0 |
| `prototype_burst` | 40 | 5.0 |
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate; remarks for NEO-78/81+ callers. |
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs` | Singleton adapter over `AbilityDefinitionCatalog`. |
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs` | AAA unit + host DI tests (mirror `RecipeDefinitionRegistryTests`). |
| `docs/plans/NEO-79-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | Register `IAbilityDefinitionRegistry``AbilityDefinitionRegistry` after catalog singleton. |
| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs` | Tighten summary to reference `IAbilityDefinitionRegistry` (replace NEO-79 placeholder). |
| `server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs` | Inject registry; migrate allowlist validation to `TryNormalizeKnown`. |
| `server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs` | Inject registry; migrate request ability validation to `TryNormalizeKnown`. |
| `server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs` | Remove static allowlist/`TryNormalizeKnown`; keep id const strings for tests. |
| `server/README.md` | Document `IAbilityDefinitionRegistry` as preferred lookup; note hotbar/cast migration. |
## Tests
| File | Coverage |
|------|----------|
| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` for `prototype_pulse` / `prototype_burst` with expected `BaseDamage` + `CooldownSeconds`; unknown id + null → false; `TryNormalizeKnown` with leading/trailing space and mixed case; empty/whitespace → false; `GetDefinitionsInIdOrder` count 4 + ordinal id order. **Loader fixture:** copy repo `prototype_abilities.json` via `AbilityCatalogTestPaths`, load catalog, assert all four freeze-box values. **Host:** `InMemoryWebApplicationFactory` resolves `IAbilityDefinitionRegistry` from DI; lookup + enumeration smoke. |
| Existing `HotbarLoadoutApiTests`, `AbilityCastApiTests`, `HotbarLoadoutPersistenceIntegrationTests`, `CooldownSnapshotApiTests` | **Regression:** no test changes expected if behavior preserved; run full AbilityInput test suite after migration. |
No manual QA for this story (server-only DI + internal migration; HTTP proof is NEO-78). **Optional Bruno deny smokes added:** `hotbar-loadout/Post loadout unknown ability deny.bru`, `ability-cast/Post cast unknown ability deny.bru` — HTTP regression for `unknown_ability` after catalog-backed validation.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|---------------------|--------|
| **Case-only mismatch** — client sends id not in catalog after normalize | **Deny `unknown_ability`** — same as today; catalog ids are lowercase frozen strings. | **adopted** |
| **Dual lookup surfaces**`TryGetDefinition` vs `TryNormalizeKnown` | **Keep both** — hotbar/cast need normalize-only; combat/HTTP need row lookup without re-implementing trim rules. | **adopted** |
| **Test const indirection** | **Keep `PrototypeAbilityRegistry` const strings** in existing tests — no mass string-literal churn. | **adopted** |
## Reconciliation (implementation)
- **`IAbilityDefinitionRegistry`** + **`AbilityDefinitionRegistry`** registered in **`AddAbilityDefinitionCatalog`**; catalog-only eager resolve unchanged in **`Program.cs`**.
- **`HotbarLoadoutApi`** / **`AbilityCastApi`** inject registry and call **`TryNormalizeKnown`**; **`PrototypeAbilityRegistry`** reduced to id constants.
- **10** new AAA tests in **`AbilityDefinitionRegistryTests`**; **31** existing AbilityInput tests unchanged and green (41 total in filtered run).
- **Bruno:** optional `unknown_ability` deny smokes under `hotbar-loadout/` and `ability-cast/` (see Tests section).

View File

@ -0,0 +1,62 @@
# Code review — NEO-79 `IAbilityDefinitionRegistry` + DI
**Date:** 2026-05-24
**Scope:** Branch `NEO-79-iabilitydefinitionregistry-di` · commits `8bafec9``b9644a0` vs `origin/main`
**Base:** `origin/main`
**Follow-up:** Code review suggestions addressed (module/alignment docs, plan Bruno note, `TryNormalizeKnown` null guard + test).
## Verdict
**Approve with nits**
## Summary
NEO-79 delivers **E5M1-03**: a thin `IAbilityDefinitionRegistry` adapter over the NEO-77 `AbilityDefinitionCatalog`, registered in DI via `AddAbilityDefinitionCatalog`, with `TryGetDefinition`, `TryNormalizeKnown`, and `GetDefinitionsInIdOrder`. Hotbar loadout and ability cast routes now inject the registry instead of the static `PrototypeAbilityRegistry` allowlist; `PrototypeAbilityRegistry` is reduced to id constants for tests. Ten new AAA unit/host tests cover lookup, normalization, enumeration, loader fixture parity, and DI resolution. Existing hotbar/cast API regression tests remain green. Bruno adds two HTTP deny smoke requests for `unknown_ability`. Implementation matches the kickoff plan and mirrors the NEO-67 recipe registry pattern. Residual risk is low — behavior-preserving internal migration; module tracking docs should catch up on merge.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-79-implementation-plan.md`](../plans/NEO-79-implementation-plan.md) | **Matches** — kickoff decisions, scope, acceptance checklist, reconciliation; all AC checked; Bruno deny smokes noted in reconciliation. |
| [`docs/plans/E5M1-prototype-backlog.md`](../plans/E5M1-prototype-backlog.md) · **E5M1-03** | **Matches** — AC checkboxes + landed note with plan link. |
| [`docs/decomposition/modules/E5_M1_CombatRulesEngine.md`](../decomposition/modules/E5_M1_CombatRulesEngine.md) | **Matches** — Status cites NEO-79 landed; freeze box updated. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E5.M1 note includes NEO-79 registry + hotbar/cast migration. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E5.M1 row includes NEO-79 landed + Bruno smokes. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server-only DI; no client or combat resolution changes. |
| [`server/README.md`](../../server/README.md) | **Matches** — ability catalog section updated for registry-first lookup and hotbar/cast validation. |
| Full-stack epic decomposition | **Matches** — plan documents no client counterpart; server-only story. |
| Manual QA | **N/A** — plan skip rationale holds; Bruno deny smokes are a useful bonus. |
## Blocking issues
None.
## Suggestions
1. ~~**Module doc status line** — In `docs/decomposition/modules/E5_M1_CombatRulesEngine.md`, change the **Status** row from “E5M1-03+ pending” to cite **E5M1-03 NEO-79 landed** (registry + hotbar/cast migration), matching the E5M1 backlog landed note.~~ **Done.** Status + freeze box updated.
2. ~~**Dependency register + alignment table** — Extend the **E5.M1 note** in `module_dependency_register.md` and the E5.M1 row in `documentation_and_implementation_alignment.md` with **NEO-79 landed:** `IAbilityDefinitionRegistry` + DI; hotbar/cast use `TryNormalizeKnown` (same pattern as NEO-77 reconciliation).~~ **Done.**
3. ~~**Plan reconciliation vs Bruno** — `NEO-79-implementation-plan.md` says “No Bruno or manual QA”; the branch adds two Bruno deny requests under `ability-cast/` and `hotbar-loadout/`. Either add a one-line reconciliation note (“optional Bruno deny smokes added”) or leave as-is — the requests are valuable HTTP regression coverage.~~ **Done.** Tests + reconciliation sections updated.
## Nits
- Nit: `TryGetDefinition` has no explicit test for case-variant ids returning false (normalization is covered on `TryNormalizeKnown` only). Acceptable given the plans split between exact lookup and normalize-for-validation.
- ~~Nit: `TryNormalizeKnown` would throw on `null` input (same as the removed static allowlist); call sites guard with `string.IsNullOrWhiteSpace` first — document in XML if future callers might skip that guard.~~ **Done.** `string?` parameter, null guard in implementation, XML remark, and unit test.
- Nit: `BuildDefinitionsInIdOrder` materializes a `List<AbilityDefRow>` at construction — same pattern as `RecipeDefinitionRegistry`; fine for four prototype rows.
## Verification
```bash
cd /home/don/neon-sprawl/server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~AbilityDefinitionRegistryTests|FullyQualifiedName~HotbarLoadoutApiTests|FullyQualifiedName~AbilityCastApiTests"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual (optional):
- Bruno: `bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru` → 200, `updated: false`, `reasonCode: unknown_ability`.
- Bruno: `bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru` → 200, `accepted: false`, `reasonCode: unknown_ability`.

View File

@ -0,0 +1,319 @@
using System.IO;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Combat;
public class AbilityDefinitionRegistryTests
{
private static AbilityDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, AbilityDefRow> byId)
{
var catalog = new AbilityDefinitionCatalog("/tmp/abilities", byId, catalogJsonFileCount: 1);
return new AbilityDefinitionRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenPulseExists()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal(25, def.BaseDamage);
Assert.Equal(3.0, def.CooldownSeconds);
Assert.Equal("attack", def.AbilityKind);
Assert.Equal("Prototype Pulse", def.DisplayName);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenBurstExists()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeBurst, out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal(40, def.BaseDamage);
Assert.Equal(5.0, def.CooldownSeconds);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenAbilityIdIsNull()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(null, out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_ability", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" Prototype_Pulse ", out var normalized);
// Assert
Assert.True(found);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(null, out var normalized);
// Assert
Assert.False(found);
Assert.Equal(string.Empty, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown(" ", out var normalized);
// Assert
Assert.False(found);
Assert.Equal(string.Empty, normalized);
}
[Fact]
public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized);
// Assert
Assert.False(found);
Assert.Equal("prototype_unknown", normalized);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleAbilities()
{
// Arrange
var rows = new Dictionary<string, AbilityDefRow>(StringComparer.Ordinal)
{
[PrototypeAbilityRegistry.PrototypeBurst] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeBurst,
"Prototype Burst",
40,
5.0,
"attack"),
[PrototypeAbilityRegistry.PrototypeDash] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeDash,
"Prototype Dash",
0,
4.0,
"movement"),
[PrototypeAbilityRegistry.PrototypeGuard] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypeGuard,
"Prototype Guard",
0,
6.0,
"utility"),
[PrototypeAbilityRegistry.PrototypePulse] = new AbilityDefRow(
PrototypeAbilityRegistry.PrototypePulse,
"Prototype Pulse",
25,
3.0,
"attack"),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(4, list.Count);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypeDash, list[1].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypeGuard, list[2].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[3].Id);
}
[Fact]
public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture()
{
// Arrange
var root = Directory.CreateTempSubdirectory("neon-sprawl-ability-registry-loader-");
try
{
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);
File.Copy(
Path.Combine(AbilityCatalogTestPaths.DiscoverRepoAbilitiesDirectory(), "prototype_abilities.json"),
Path.Combine(abilitiesDir, "prototype_abilities.json"),
overwrite: true);
var loaded = AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, NullLogger.Instance);
var registry = new AbilityDefinitionRegistry(loaded);
// Act
var pulseOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var pulse);
var guardOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeGuard, out var guard);
var dashOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeDash, out var dash);
var burstOk = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypeBurst, out var burst);
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.True(pulseOk);
Assert.NotNull(pulse);
Assert.Equal(25, pulse!.BaseDamage);
Assert.Equal(3.0, pulse.CooldownSeconds);
Assert.True(guardOk);
Assert.NotNull(guard);
Assert.Equal(0, guard!.BaseDamage);
Assert.Equal(6.0, guard.CooldownSeconds);
Assert.True(dashOk);
Assert.NotNull(dash);
Assert.Equal(0, dash!.BaseDamage);
Assert.Equal(4.0, dash.CooldownSeconds);
Assert.True(burstOk);
Assert.NotNull(burst);
Assert.Equal(40, burst!.BaseDamage);
Assert.Equal(5.0, burst.CooldownSeconds);
Assert.Equal(4, list.Count);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, list[0].Id);
Assert.Equal(PrototypeAbilityRegistry.PrototypePulse, list[^1].Id);
}
finally
{
try
{
Directory.Delete(root.FullName, recursive: true);
}
catch (IOException)
{
// Best-effort: transient lock or race on some hosts; temp dir is unique per run.
}
}
}
[Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IAbilityDefinitionRegistry>();
var pulseFound = registry.TryGetDefinition(PrototypeAbilityRegistry.PrototypePulse, out var pulse);
var unknown = registry.TryGetDefinition("not_a_real_ability", out var missing);
var normalized = registry.TryNormalizeKnown(" Prototype_Burst ", out var burstId);
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.True(pulseFound);
Assert.NotNull(pulse);
Assert.Equal(25, pulse!.BaseDamage);
Assert.Equal(3.0, pulse.CooldownSeconds);
Assert.False(unknown);
Assert.Null(missing);
Assert.True(normalized);
Assert.Equal(PrototypeAbilityRegistry.PrototypeBurst, burstId);
Assert.Equal(4, list.Count);
Assert.Contains(list, a => a.Id == PrototypeAbilityRegistry.PrototypeGuard && a.BaseDamage == 0);
}
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Diagnostics;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Game.World;
@ -57,6 +58,7 @@ public static class AbilityCastApi
IPlayerHotbarLoadoutStore store,
IPlayerTargetLockStore locks,
IPlayerAbilityCooldownStore cooldowns,
IAbilityDefinitionRegistry abilities,
TimeProvider clock) =>
{
// NEO-30: not a Slice 3 `ability_cast_denied` site — no AbilityCastResponse body.
@ -105,7 +107,7 @@ public static class AbilityCastApi
}
if (string.IsNullOrWhiteSpace(body.AbilityId) ||
!PrototypeAbilityRegistry.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
!abilities.TryNormalizeKnown(body.AbilityId, out var normalizedRequest))
{
// NEO-30 telemetry hook site: `ability_cast_denied` + reasonCode (TODO(E9.M1): catalog emit).
return JsonAbilityCast(

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.AbilityInput;
@ -30,7 +31,7 @@ public static class HotbarLoadoutApi
app.MapPost(
"/game/players/{id}/hotbar-loadout",
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store) =>
(string id, HotbarLoadoutUpdateRequest? body, IPositionStateStore positions, IPlayerHotbarLoadoutStore store, IAbilityDefinitionRegistry abilities) =>
{
if (body is null || body.SchemaVersion != HotbarLoadoutUpdateRequest.CurrentSchemaVersion)
{
@ -75,7 +76,7 @@ public static class HotbarLoadoutApi
}
if (string.IsNullOrWhiteSpace(update.AbilityId) ||
!PrototypeAbilityRegistry.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
!abilities.TryNormalizeKnown(update.AbilityId, out var normalizedAbilityId))
{
return Results.Json(
new HotbarLoadoutUpdateResponse

View File

@ -1,29 +1,10 @@
namespace NeonSprawl.Server.Game.AbilityInput;
/// <summary>Prototype ability allowlist for NEO-29 hotbar bindings.</summary>
/// <summary>Stable prototype ability id constants for tests and fixtures (NEO-29). Allowlist validation uses <see cref="Combat.IAbilityDefinitionRegistry"/>.</summary>
public static class PrototypeAbilityRegistry
{
public const string PrototypePulse = "prototype_pulse";
public const string PrototypeGuard = "prototype_guard";
public const string PrototypeDash = "prototype_dash";
public const string PrototypeBurst = "prototype_burst";
private static readonly HashSet<string> Allowed = new(StringComparer.Ordinal)
{
PrototypePulse,
PrototypeGuard,
PrototypeDash,
PrototypeBurst,
};
public static bool TryNormalizeKnown(string rawAbilityId, out string normalized)
{
normalized = rawAbilityId.Trim().ToLowerInvariant();
if (normalized.Length == 0)
{
return false;
}
return Allowed.Contains(normalized);
}
}

View File

@ -8,7 +8,7 @@ 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>
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="AbilityDefinitionCatalog"/> and <see cref="IAbilityDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
@ -32,6 +32,9 @@ public static class AbilityCatalogServiceCollectionExtensions
return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger);
});
services.AddSingleton<IAbilityDefinitionRegistry>(sp =>
new AbilityDefinitionRegistry(sp.GetRequiredService<AbilityDefinitionCatalog>()));
return services;
}
}

View File

@ -2,7 +2,7 @@ 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>
/// <summary>In-memory ability catalog loaded at startup (NEO-77). Game callers should use <see cref="IAbilityDefinitionRegistry"/>.</summary>
public sealed class AbilityDefinitionCatalog(
string abilitiesDirectory,
IReadOnlyDictionary<string, AbilityDefRow> byId,

View File

@ -0,0 +1,55 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>Adapter over <see cref="AbilityDefinitionCatalog"/> (NEO-79).</summary>
public sealed class AbilityDefinitionRegistry(AbilityDefinitionCatalog catalog) : IAbilityDefinitionRegistry
{
private readonly IReadOnlyList<AbilityDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
/// <inheritdoc />
public bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition)
{
if (abilityId is null)
{
definition = null;
return false;
}
return catalog.TryGetAbility(abilityId, out definition);
}
/// <inheritdoc />
public bool TryNormalizeKnown(string? rawAbilityId, [NotNullWhen(true)] out string normalized)
{
if (rawAbilityId is null)
{
normalized = string.Empty;
return false;
}
normalized = rawAbilityId.Trim().ToLowerInvariant();
if (normalized.Length == 0)
{
normalized = string.Empty;
return false;
}
return catalog.TryGetAbility(normalized, out _);
}
/// <inheritdoc />
public IReadOnlyList<AbilityDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
private static IReadOnlyList<AbilityDefRow> BuildDefinitionsInIdOrder(AbilityDefinitionCatalog catalog)
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<AbilityDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Combat;
/// <summary>
/// Read-only access to validated <see cref="AbilityDefRow"/> entries loaded at startup (<see cref="AbilityDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E5.M1 (combat / hotbar / cast):</b> callers validating ability ids and resolving damage/cooldown metadata should depend on this interface
/// rather than <see cref="AbilityDefinitionCatalog"/> so ability defs stay centralized.</para>
/// <para><b>NEO-78:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IAbilityDefinitionRegistry
{
/// <summary>Attempts to resolve an ability by stable <c>id</c> (see <c>ability-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawAbilityId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog. <c>null</c>, empty, and whitespace return <c>false</c> without throwing.</summary>
bool TryNormalizeKnown(string? rawAbilityId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="AbilityDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<AbilityDefRow> GetDefinitionsInIdOrder();
}

View File

@ -91,7 +91,7 @@ On startup the host loads every **`*_abilities.json`** under the abilities direc
**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.
On success, **Information** logs include the resolved abilities directory path, distinct ability count, and catalog file count. Game code should use **`IAbilityDefinitionRegistry`** for lookups (NEO-79). The catalog singleton remains for fail-fast startup only; do not inject **`AbilityDefinitionCatalog`** in new game code. Hotbar loadout and ability cast routes validate ability ids via **`IAbilityDefinitionRegistry.TryNormalizeKnown`** (backed by the loaded catalog, not a separate static allowlist).
## Recipe definitions (NEO-68)