neon-sprawl/docs/plans/NEO-46-implementation-plan.md

136 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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-4749).
- Client HUD.
- Applying `effectKind` gameplay modifiers.
## Acceptance criteria checklist
- [x] Fail-fast boot load for mastery catalog(s) under agreed content path.
- [x] `IMasteryCatalogRegistry` resolves tracks by `skillId` and enumerates perk definitions.
- [x] Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`.
- [x] 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 *N1*
- **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. |