136 lines
11 KiB
Markdown
136 lines
11 KiB
Markdown
# 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
|
||
|
||
- [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 *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. |
|