From 898f935b4ffdf52e0d0054c43790da86603311c8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 18:03:30 -0400 Subject: [PATCH] NEO-46: add implementation plan for mastery catalog boot load. --- docs/plans/NEO-46-implementation-plan.md | 135 +++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/plans/NEO-46-implementation-plan.md diff --git a/docs/plans/NEO-46-implementation-plan.md b/docs/plans/NEO-46-implementation-plan.md new file mode 100644 index 0000000..2607430 --- /dev/null +++ b/docs/plans/NEO-46-implementation-plan.md @@ -0,0 +1,135 @@ +# NEO-46 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-46 | +| **Title** | E2.M3: Server loads mastery catalog at startup (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast | +| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 | +| **Branch** | `NEO-46-mastery-catalog-startup-load` | +| **Blocked by** | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) (content + CI) — **Done** on `main` | + +## Kickoff clarifications + +| Topic | Question | Answer | +|--------|----------|--------| +| **Code location** | `Game/Skills/` vs `Game/Mastery/`? | **`Game/Mastery/`** — E2.M3 boundary; NEO-47+ (unlock engine, `PerkState`, HTTP) stays cohesive. | +| **`tierIndex` gate** | Enforce uniqueness + sequential 1..N at server boot? | **Yes** — stricter than CI today; protects NEO-47 `PerkState` keys. | +| **Validation strategy** | C# in-process vs subprocess? | **In-process C#** mirroring [`scripts/validate_content.py`](../../scripts/validate_content.py) (NEO-34 precedent). | +| **NEO-45 gate parity** | Which Python constants/rules? | Mirror `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, Slice 4 single-track gate, unknown `skillId`, duplicate `perkId`, branch-set equality, unreferenced `perks`, branch/perk reference integrity — see [NEO-45 plan](NEO-45-implementation-plan.md) and E2.M3 **NEO-46 handoff**. | + +## Goal, scope, and out-of-scope + +**Goal:** On host startup, load all `content/mastery/*_mastery.json` files validated in CI, build an in-memory mastery catalog, **cross-check every track `skillId` against `ISkillDefinitionRegistry`**, and **refuse to listen** if any file is missing, malformed, or violates prototype gates. Expose read-only **`IMasteryCatalogRegistry`** for NEO-47+. + +**In scope (from Linear):** + +- Fail-fast boot load for mastery catalog(s) under agreed content path. +- `IMasteryCatalogRegistry` resolves tracks by `skillId` and perk metadata by `perkId`. +- Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`. +- Unit tests (AAA) for loader happy path + duplicate id / unknown skill deny. +- Host-level test: valid repo catalog → `WebApplicationFactory` starts; invalid fixture → startup fails with actionable message. + +**Out of scope (from Linear):** + +- Per-player perk state, unlock engine, HTTP (NEO-47–49). +- Client HUD. +- Applying `effectKind` gameplay modifiers. + +## Acceptance criteria checklist + +- [ ] Fail-fast boot load for mastery catalog(s) under agreed content path. +- [ ] `IMasteryCatalogRegistry` resolves tracks by `skillId` and enumerates perk definitions. +- [ ] Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`. +- [ ] Unit tests for loader happy path + duplicate id / unknown skill deny. + +## Technical approach + +1. **Configuration:** Extend [`ContentPathsOptions`](../../server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs) with `MasteryDirectory` and optional `MasteryCatalogSchemaPath`. When unset, discover `content/mastery` by walking ancestors of `AppContext.BaseDirectory` (same pattern as `content/skills`). Schema default: `{parent of mastery dir}/schemas/mastery-catalog.schema.json`. +2. **Path resolution:** Add `MasteryCatalogPathResolution` under `Game/Mastery/` (mirror `SkillCatalogPathResolution`). +3. **Loader (`MasteryCatalogLoader`):** Enumerate `*_mastery.json` (top-level only, sorted). Per file: + - Parse JSON; validate whole document against `mastery-catalog.schema.json` via **JsonSchema.Net** (already on server from NEO-34). + - Post-schema gates aligned with [`validate_content.py`](../../scripts/validate_content.py) `_validate_mastery_catalogs` + `_prototype_slice4_gate`: + - `perks` map key == `PerkDef.id` + - Global duplicate `perkId` across files + - Global duplicate branch `perkIds` reference (one reference site per perk id catalog-wide) + - Unknown `perkIds` references; unreferenced `perks` entries + - Unknown `skillId` (against skill ids passed in from `ISkillDefinitionRegistry` / loaded skill catalog) + - Per-tier: duplicate `branchId`; `requiredLevel` strictly increasing; tier *N* `branchId` set equals tier *N−1* + - **Slice 4:** exactly one track across all files; sole track `skillId` == `salvage` (`PrototypeSlice4MasteryCatalogRules`, constant cross-referenced to Python `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`) + - **Server-only (kickoff):** per track, `tierIndex` values unique and exactly `{1..N}` with no gaps (schema allows gaps; prototype catalog is 1..2). +4. **Models:** Immutable POCOs — `MasteryCatalog`, `MasteryTrackRow`, `MasteryTierRow`, `MasteryBranchRow`, `PerkDefRow` — enough for registry lookups and logging. +5. **Registry:** `IMasteryCatalogRegistry` with `TryGetTrack(string? skillId, out MasteryTrackRow?)`, `TryGetPerk(string? perkId, out PerkDefRow?)`, `GetTracksInSkillIdOrder()`, `GetPerksInIdOrder()` (or equivalent; name flexible). +6. **DI:** `AddMasteryCatalog(IServiceCollection, IConfiguration)` registers `MasteryCatalog` + `IMasteryCatalogRegistry`; loader receives skill ids from `SkillDefinitionCatalog` (resolve skill catalog first in factory delegate). **Eager resolve** in `Program.cs` after skill catalog (same as NEO-34). +7. **Logging:** Information log with resolved mastery directory, file count, track count, perk count. +8. **Docs:** `server/README.md` — `Content:MasteryDirectory` / schema override; note server `tierIndex` gate. Optional one-line pointer in E2.M3 module if implementation touches handoff section (only if needed for accuracy). + +### Gate constant parity (C# ↔ Python) + +| Rule | Python | C# (planned) | +|------|--------|----------------| +| Slice 4 sole track skill | `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID` | `PrototypeSlice4MasteryCatalogRules.SalvageSkillId` | +| Slice 4 track count | `_prototype_slice4_gate` | `TryGetSlice4GateError(trackSkillIds)` | + +Comment in C# constants: `// Keep in sync with scripts/validate_content.py`. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Mastery/ContentPathsMasteryExtensions.cs` | Optional: mastery path helpers if not folded into shared resolution type. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs` | Discover / resolve `content/mastery` and schema paths. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs` | File I/O, schema validation, post-schema gates, skill cross-check. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs` | Immutable load result (tracks by `skillId`, perks by id). | +| `server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs` | DTO for one track after validation. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs` | Tier row (`tierIndex`, `requiredLevel`, branches). | +| `server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs` | Branch row (`branchId`, `displayName`, `perkIds`). | +| `server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs` | Perk metadata row. | +| `server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs` | Slice 4 gate (single salvage track). | +| `server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs` | Read-only lookup contract for game code. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs` | Adapter over `MasteryCatalog`. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs` | `AddMasteryCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs` | Repo schema / fixture path discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs` | Loader unit tests (happy + failure modes). | +| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs` | Registry lookup tests + prototype fixture parity. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `MasteryDirectory`, `MasteryCatalogSchemaPath`. | +| `server/NeonSprawl.Server/Program.cs` | Register mastery catalog; eager-resolve after skill catalog. | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Set `Content:MasteryDirectory` (discover repo `content/mastery`) so host tests load real prototype catalog. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Same mastery path override as in-memory factory (if present). | +| `server/README.md` | Document mastery content paths and fail-fast behavior. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: note NEO-46 server load when landed (during implementation). | + +## Tests + +| Test file | Coverage | +|-----------|----------| +| `MasteryCatalogLoaderTests.cs` | **Happy:** temp dir with copy of repo schema + minimal valid catalog matching prototype salvage shape → load succeeds, track/perk counts. **Deny:** malformed JSON; schema violation; unknown `skillId` (skill catalog stub with only `refine`); duplicate `perkId` across files; duplicate global `perkIds` reference; orphan `perks` entry; Slice 4 wrong track count / wrong `skillId`; **`tierIndex`** gap or duplicate. Assert `InvalidOperationException` messages include file path. | +| `MasteryCatalogRegistryTests.cs` | `TryGetTrack("salvage")`, `TryGetPerk("salvage_scrap_efficiency_1")`; unknown ids return false; ordered enumeration. | +| `MasteryCatalogLoaderTests.cs` (host) | `InMemoryWebApplicationFactory` + `/health` + DI `IMasteryCatalogRegistry` resolves salvage track from repo content. | +| `MasteryCatalogLoaderTests.cs` (host negative) | Invalid mastery directory override → host fails startup (mirror `SkillDefinitionCatalogLoaderTests` host case). | + +All new `[Fact]` methods use AAA with `// Arrange` / `// Act` / `// Assert` labels per [csharp-style](../../.cursor/rules/csharp-style.md). + +## Open questions / risks + +- **C# vs Python drift:** Mitigate with shared constant names documented in both places; optional follow-up to add `tierIndex` gate to `validate_content.py` (not required for NEO-46). +- **Test discovery:** `dotnet test` from `server/` must find `content/mastery` via same ancestor walk as skills; verify in CI. +- **Load order:** Mastery loader must run after skill catalog is available in DI (factory dependency on `SkillDefinitionCatalog`). + +None blocking after kickoff clarifications. + +## Decisions + +| Decision | Rationale | +|----------|-----------| +| `Game/Mastery/` folder | User-approved kickoff; separates E2.M3 from E2.M1 skill catalog. | +| `tierIndex` 1..N gate at server boot | User-approved kickoff; protects NEO-47 persistence keys. | +| In-process C# validation | NEO-34 precedent; no Python on server images. | +| Cross-check skill ids from loaded `SkillDefinitionCatalog` | Linear AC; single source of truth with E2.M1. |