diff --git a/bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru b/bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru new file mode 100644 index 0000000..39f1aa5 --- /dev/null +++ b/bruno/neon-sprawl-server/ability-cast/Post cast unknown ability deny.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru b/bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru new file mode 100644 index 0000000..76eb857 --- /dev/null +++ b/bruno/neon-sprawl-server/hotbar-loadout/Post loadout unknown ability deny.bru @@ -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"); + }); +} diff --git a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md index bf7b722..4b588a7 100644 --- a/docs/decomposition/modules/E5_M1_CombatRulesEngine.md +++ b/docs/decomposition/modules/E5_M1_CombatRulesEngine.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M1 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — Slice 1 backlog [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md): E5M1-01 [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) ability catalog + CI landed; E5M1-02 [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) diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 5894324..1d59e0d 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -56,7 +56,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **NEO-75 landed:** capstone gather→refine→make loop in Godot — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), `client/README.md` end-to-end section; Epic 3 Slice 5 client complete. | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | -| E5.M1 | In Progress | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **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) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index d31efc4..d76623c 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -76,7 +76,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen | E5.M3 | EncounterAndRewardTables | E5.M1, E3.M3, E7.M2 | EncounterDef, RewardTable, EncounterCompleteEvent | Prototype | Planned | | E5.M4 | GroupCombatScaling | E5.M3, E8.M1 | ScalingProfile, PartySizeModifier, CombatDifficultyBand | Pre-production | Planned | -**E5.M1 note:** Epic 5 **Slice 1** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) → [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86); label **`E5.M1`**. Gig XP on defeat: **E5M1-10** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1_CombatRulesEngine.md](E5_M1_CombatRulesEngine.md). Builds on E1.M4 cast funnel (NEO-28/31/32); extends **`POST …/ability-cast`** with **`CombatResolution`**. **NEO-76 landed:** frozen four-ability catalog + CI ([NEO-76 plan](../../plans/NEO-76-implementation-plan.md)). **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 diff --git a/docs/plans/E5M1-prototype-backlog.md b/docs/plans/E5M1-prototype-backlog.md index 00795c1..47fd303 100644 --- a/docs/plans/E5M1-prototype-backlog.md +++ b/docs/plans/E5M1-prototype-backlog.md @@ -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). --- diff --git a/docs/plans/NEO-79-implementation-plan.md b/docs/plans/NEO-79-implementation-plan.md new file mode 100644 index 0000000..d824291 --- /dev/null +++ b/docs/plans/NEO-79-implementation-plan.md @@ -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 today’s 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). diff --git a/docs/reviews/2026-05-24-NEO-79.md b/docs/reviews/2026-05-24-NEO-79.md new file mode 100644 index 0000000..ac3a213 --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-79.md @@ -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 plan’s 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` 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`. diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs new file mode 100644 index 0000000..40a3a60 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs @@ -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 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(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(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(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(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(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(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(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(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(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(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index fec55fc..763c996 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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( diff --git a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs index 0e3dec3..ad66490 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/HotbarLoadoutApi.cs @@ -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 diff --git a/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs b/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs index fd1a2ca..dd0771b 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs @@ -1,29 +1,10 @@ namespace NeonSprawl.Server.Game.AbilityInput; -/// Prototype ability allowlist for NEO-29 hotbar bindings. +/// Stable prototype ability id constants for tests and fixtures (NEO-29). Allowlist validation uses . 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 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); - } } diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs index 68e7c9f..9644fc5 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ namespace NeonSprawl.Server.Game.Combat; /// DI registration for the fail-fast ability catalog (NEO-77). public static class AbilityCatalogServiceCollectionExtensions { - /// Binds and registers as a singleton. + /// Binds and registers and as singletons. public static IServiceCollection AddAbilityDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -32,6 +32,9 @@ public static class AbilityCatalogServiceCollectionExtensions return AbilityDefinitionCatalogLoader.Load(abilitiesDir, schemaPath, logger); }); + services.AddSingleton(sp => + new AbilityDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs index 1c5b10f..4c622de 100644 --- a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs @@ -2,7 +2,7 @@ using System.Collections.ObjectModel; namespace NeonSprawl.Server.Game.Combat; -/// In-memory ability catalog loaded at startup (NEO-77). Game callers should use IAbilityDefinitionRegistry (NEO-79). +/// In-memory ability catalog loaded at startup (NEO-77). Game callers should use . public sealed class AbilityDefinitionCatalog( string abilitiesDirectory, IReadOnlyDictionary byId, diff --git a/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs new file mode 100644 index 0000000..c47cbc9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/AbilityDefinitionRegistry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Combat; + +/// Adapter over (NEO-79). +public sealed class AbilityDefinitionRegistry(AbilityDefinitionCatalog catalog) : IAbilityDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition) + { + if (abilityId is null) + { + definition = null; + return false; + } + + return catalog.TryGetAbility(abilityId, out definition); + } + + /// + 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 _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(AbilityDefinitionCatalog catalog) + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs new file mode 100644 index 0000000..7e3ac38 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Combat; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E5.M1 (combat / hotbar / cast): callers validating ability ids and resolving damage/cooldown metadata should depend on this interface +/// rather than so ability defs stay centralized. +/// NEO-78: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IAbilityDefinitionRegistry +{ + /// Attempts to resolve an ability by stable id (see ability-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? abilityId, [NotNullWhen(true)] out AbilityDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. null, empty, and whitespace return false without throwing. + bool TryNormalizeKnown(string? rawAbilityId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/README.md b/server/README.md index 0277adf..45021c9 100644 --- a/server/README.md +++ b/server/README.md @@ -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)