From 07444e45ceb38dbf3739df2d3b6290cf7d9a4d12 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 21:34:36 -0400 Subject: [PATCH] NEO-77: Add E5M1-02 server ability catalog load kickoff plan. --- docs/plans/NEO-77-implementation-plan.md | 129 +++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/plans/NEO-77-implementation-plan.md diff --git a/docs/plans/NEO-77-implementation-plan.md b/docs/plans/NEO-77-implementation-plan.md new file mode 100644 index 0000000..5a104d9 --- /dev/null +++ b/docs/plans/NEO-77-implementation-plan.md @@ -0,0 +1,129 @@ +# NEO-77 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-77 | +| **Title** | E5M1-02: Server ability catalog load (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-77/e5m1-02-server-ability-catalog-load-fail-fast | +| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-02** | +| **Branch** | `NEO-77-e5m1-server-ability-catalog-load` | +| **Precursor** | [NEO-76](https://linear.app/neon-sprawl/issue/NEO-76) — frozen four-ability catalog + CI gates (**Done** on `main`) | +| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) / [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket | +| **Blocks** | [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) — `IAbilityDefinitionRegistry` + DI (E5M1-03) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **NEO-77 vs NEO-79 (DI scope)** | Ship `IAbilityDefinitionRegistry` on this story, or only load + `AbilityDefinitionCatalog`? | **Strict split** — NEO-77: loader + `AbilityDefinitionCatalog` + eager fail-fast boot; [NEO-79](https://linear.app/neon-sprawl/issue/NEO-79) adds injectable `IAbilityDefinitionRegistry` (E5M1-03). Rationale: E5M1 backlog separates E5M1-02/03; mirrors NEO-66/NEO-67. | **User:** `AbilityDefinitionCatalog` singleton only (kickoff). | +| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` / `RecipeDefinitionCatalogLoaderTests`. | **User:** loader AAA + host boot tests (kickoff). | +| **`PrototypeAbilityRegistry`** | Migrate hotbar/cast allowlist reads this story? | **Leave unchanged** — E5M1-03 explicitly migrates call sites; NEO-77 is load-only. | **User:** unchanged (kickoff). | +| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + E5M1 four-id gate). Rationale: NEO-51 / NEO-66 precedent; no cross-catalog refs in Slice 1. | **Adopted** — NEO-51 precedent; no separate question. | +| **Lookup API on NEO-77** | How does “catalog resolves `AbilityDef` by `id`” land without NEO-79? | **`AbilityDefinitionCatalog.ById`** (read-only map) + `TryGetAbility(string id, out AbilityDefRow row)`; row carries damage + cooldown fields for NEO-79 consumers. | **Adopted** — NEO-66 `RecipeDefinitionCatalog` precedent. | + +## Goal, scope, and out-of-scope + +**Goal:** On host startup, load all `content/abilities/*_abilities.json` catalogs (validated in CI per [NEO-76](NEO-76-implementation-plan.md)), build an in-memory **`AbilityDefinitionCatalog`**, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype E5M1 four-id gate. + +**In scope (from Linear + [E5M1-02](E5M1-prototype-backlog.md#e5m1-02--server-ability-catalog-load-fail-fast)):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Combat/`. +- Configurable abilities directory and schema path (`ContentPathsOptions` extension). +- CI-parity validation: `schemaVersion`, row schema (`ability-def`), duplicate `id`, E5M1 four-id allowlist gate. +- DI registration of **`AbilityDefinitionCatalog`** singleton; eager resolve in `Program.cs`. +- Unit + host startup tests (AAA). +- `server/README.md` section. + +**Out of scope (from Linear):** + +- **`IAbilityDefinitionRegistry`** / hotbar-cast migration from **`PrototypeAbilityRegistry`** (**NEO-79**, E5M1-03). +- HTTP projection (**NEO-78**, E5M1-04), combat resolution (**NEO-81+**). + +## Acceptance criteria checklist + +- [ ] Server refuses boot when ability catalog invalid (missing dir, no `*_abilities.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, E5M1 gate failure). +- [ ] `AbilityDefinitionCatalog` resolves prototype rows by ability `id` (e.g. `prototype_pulse`, `prototype_burst`) with `baseDamage`, `cooldownSeconds`, optional `abilityKind`. +- [ ] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. +- [ ] Success log includes **ability count**, **catalog directory**, and **file count** (Information). + +## Technical approach + +1. **`Game/Combat/` row DTO** — `AbilityDefRow` (`Id`, `DisplayName`, `BaseDamage`, `CooldownSeconds`, optional `AbilityKind` enum or string). + +2. **`AbilityDefinitionCatalogLoader.Load(abilitiesDirectory, schemaPath, logger)`** — single-pass load mirroring `validate_content.py` `_validate_ability_catalogs` + `_prototype_e5m1_ability_gate`: + - Enumerate `*_abilities.json` (top-level only), ordered by path. + - Build **JsonSchema.Net** evaluator from `ability-def.schema.json`. + - Per file: parse JSON; require `schemaVersion == 1` and top-level `abilities` array. + - Per row: validate against schema; on clean rows track `id`; build `AbilityDefRow`; reject duplicate `id` across files. + - Run **`PrototypeE5M1AbilityCatalogRules.TryGetE5M1GateError`** (mirror `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate` in `scripts/validate_content.py`). + - Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Ability catalog validation failed:`. + +3. **`AbilityDefinitionCatalog`** — immutable snapshot: `AbilitiesDirectory`, `ById`, `DistinctAbilityCount`, `CatalogJsonFileCount`; `TryGetAbility(id, out AbilityDefRow?)` for lookups. + +4. **`AbilityCatalogPathResolution`** — `TryDiscoverAbilitiesDirectory`, `ResolveAbilitiesDirectory`, `ResolveAbilityDefSchemaPath` (parent `content/schemas/` layout when unset). + +5. **`PrototypeE5M1AbilityCatalogRules`** — frozen four ability ids; sync comment to `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate`. Ids: `prototype_pulse`, `prototype_guard`, `prototype_dash`, `prototype_burst` (same set as [`PrototypeAbilityRegistry`](../../server/NeonSprawl.Server/Game/AbilityInput/PrototypeAbilityRegistry.cs) — unchanged this story). + +6. **`AbilityCatalogServiceCollectionExtensions.AddAbilityDefinitionCatalog`** — bind `ContentPathsOptions`; register **`AbilityDefinitionCatalog`** singleton factory that resolves paths and calls loader. No dependency on item/skill catalogs (Slice 1 abilities have no cross-ref fields). + +7. **`Program.cs`** — `AddAbilityDefinitionCatalog(configuration)` alongside existing catalog registrations; after `Build()`, `GetRequiredService()` with other eager catalog resolves. + +8. **Tests** — temp-dir fixtures copying repo `ability-def.schema.json`; valid four-ability JSON matching [prototype_abilities.json](../../content/abilities/prototype_abilities.json); negative cases (duplicate id, wrong/missing E5M1 roster, bad `baseDamage` / `cooldownSeconds`, empty `displayName`, `schemaVersion` ≠ 1, missing dir/schema, no `*_abilities.json`, invalid JSON); `InMemoryWebApplicationFactory` host test + `WebApplicationFactory` with empty abilities dir fails startup. + +9. **Test factories** — pin `Content:AbilitiesDirectory` in `InMemoryWebApplicationFactory` and other `WebApplicationFactory` subclasses that already pin recipes/items (same pattern as NEO-66). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Combat/AbilityDefRow.cs` | Load-time `AbilityDef` row DTO. | +| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalog.cs` | In-memory catalog + `TryGetAbility`. | +| `server/NeonSprawl.Server/Game/Combat/AbilityDefinitionCatalogLoader.cs` | Disk I/O, schema validation, E5M1 gate. | +| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogPathResolution.cs` | Directory/schema discovery and config resolution. | +| `server/NeonSprawl.Server/Game/Combat/PrototypeE5M1AbilityCatalogRules.cs` | Frozen four ids gate (sync comment to Python). | +| `server/NeonSprawl.Server/Game/Combat/AbilityCatalogServiceCollectionExtensions.cs` | `AddAbilityDefinitionCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityCatalogTestPaths.cs` | Repo `content/abilities` + schema discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. | +| `docs/plans/NEO-77-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `AbilitiesDirectory`, `AbilityDefSchemaPath` under `Content` section. | +| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `AbilityDefinitionCatalog` at startup. | +| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:AbilitiesDirectory` / schema path keys for override documentation (mirror other catalogs). | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:AbilitiesDirectory` to discovered repo path so existing tests keep booting. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Pin abilities directory (same as recipes/items). | +| `server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. | +| `server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. | +| `server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs` | Pin abilities directory. | +| `server/README.md` | New **Ability catalog (`content/abilities`, NEO-77)** section (config keys, discovery, fail-fast, E5M1 parity with CI). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M1 row — note NEO-77 server load when implementation completes. | +| `docs/decomposition/modules/E5_M1_CombatRulesEngine.md` | Status line — E5M1-02 server load landed when complete. | +| `docs/plans/E5M1-prototype-backlog.md` | E5M1-02 checkboxes when implementation completes. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionCatalogLoaderTests.cs` | **Unit:** valid four-ability prototype catalog loads; `abilities` not array; schema violation (negative `baseDamage`, zero `cooldownSeconds`, empty `displayName`); duplicate `id` across files; wrong/missing E5M1 ids; `schemaVersion` ≠ 1; missing dir/schema; no `*_abilities.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `AbilityDefinitionCatalog` count 4; `WebApplicationFactory` with empty abilities dir fails startup with actionable message. | + +**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA loader/host tests above. + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Constants drift vs Python** | Comment on `PrototypeE5M1AbilityCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E5M1_ABILITY_IDS` / `_prototype_e5m1_ability_gate`. | **adopted** | +| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:AbilitiesDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** | +| **Registry consumers** | Hotbar/cast/combat use `IAbilityDefinitionRegistry` in NEO-79+; this story only exposes `AbilityDefinitionCatalog` for boot + direct test lookup. **`PrototypeAbilityRegistry` unchanged.** | **adopted** | +| **Global cooldown still active** | Expected — catalog lands before E5M1-07 wires per-ability `cooldownSeconds`; load does not change runtime cast behavior. | **adopted** | + +## Decisions (kickoff) + +- **`AbilityDefinitionCatalog` singleton only** — defer `IAbilityDefinitionRegistry` and `PrototypeAbilityRegistry` migration to NEO-79 (user confirmed kickoff). +- **In-process validation** + **loader and host boot tests** per NEO-51 / NEO-66 precedent. +- **No cross-catalog checks** — ability rows have no item/skill refs in Slice 1; loader has no catalog dependencies.