diff --git a/docs/plans/NEO-145-implementation-plan.md b/docs/plans/NEO-145-implementation-plan.md new file mode 100644 index 0000000..140b34b --- /dev/null +++ b/docs/plans/NEO-145-implementation-plan.md @@ -0,0 +1,185 @@ +# NEO-145 — E7M4-02: Server contract template catalog load (fail-fast) + +**Linear:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) +**Branch:** `NEO-145-e7m4-02-server-contract-template-catalog-load-fail-fast` +**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-02** +**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) +**Pattern:** [NEO-134-implementation-plan.md](NEO-134-implementation-plan.md) (faction catalog load + CI-parity gates); [NEO-113-implementation-plan.md](NEO-113-implementation-plan.md) (quest catalog cross-ref deps); [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md) (encounter registry + DI) +**Precursor:** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) **`Done`** — `contract-template` + `contract-seed` schemas, `prototype_contract_templates.json`, E7M4 CI gates (**merged to `main`**) +**Blocks:** [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) (contract instance + outcome stores) +**Client counterpart:** none (infrastructure-only); playable loop is **NEO-154** (E7M4-11) + +## Goal + +Fail-fast startup load of `content/contracts/*_contract_templates.json` with **CI-parity** validation; cross-check **`encounterTemplateId`** and bundle refs against loaded catalogs. Host refuses to listen when contract content is missing, malformed, or fails prototype E7M4 gates. **`IContractTemplateRegistry`** resolves template metadata by id for E7M4-04+. + +## Kickoff clarifications + +**No blocking decisions needed.** Linear AC, [E7M4-02 backlog scope](E7M4-pre-production-backlog.md#e7m4-02--server-contract-template-catalog-load-fail-fast), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), NEO-144 shipped CI gates, and NEO-134/NEO-113 precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| CI parity scope | Full E7M4 gates on server (schema, roster, freeze, cross-ref, band caps) | Backlog “CI-parity validation”; NEO-134 full E7M3 gate precedent | +| Registry in E7M4-02 | Ship **`IContractTemplateRegistry`** + DI now | Backlog in-scope list; unlike E5M3-02/03 split, E7M4-02 explicitly includes registry | +| Runtime validation | In-process **C#** mirroring `scripts/validate_content.py` | NEO-51/134 precedent; no Python on server images | +| Cross-ref deps | Encounter, item, skill, faction catalogs at load | Python `_prototype_e7m4_cross_ref_gate`; quest E7M2 `mission_reward` allowlist on skill XP | +| Load order | After item, skill, faction, encounter, quest catalogs | All cross-ref sources resolved; contracts do not affect quest load | +| Band caps | Port **`PROTOTYPE_E7M4_BAND_CAPS`** to C# in loader gates now | NEO-144 review + plan open question; E7M4-07 reuses same constants at issue time | +| Bundle DTO reuse | Reuse **`QuestRewardBundleRow`**, **`FactionGateRuleRow`** from `Game/Quests/` | Same `$ref`s as quest catalog; avoids duplicate grant row types | +| Instance store / generator / HTTP / Godot | **Out of scope** | Backlog E7M4-02; NEO-146+ | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Contracts/`. +- **`IContractTemplateRegistry`** + DI registration. +- Cross-check **`encounterTemplateId`** vs loaded encounter catalog; bundle item/skill/faction refs + **`mission_reward`** allowlist (CI parity). +- **`PrototypeE7M4ContractCatalogRules`** — roster, freeze, cross-ref, band-cap gates synced to `scripts/validate_content.py` `PROTOTYPE_E7M4_*` + `_prototype_e7m4_*`. +- `ContentPathsOptions` + path resolution for `content/contracts/`. +- Unit tests (AAA) for loader happy path and failure modes (broken encounter ref, roster/freeze/band-cap); host boot resolves contract catalog. +- `server/README.md` contract catalog section. + +**Out of scope (from Linear + backlog):** + +- Per-player **`ContractInstance`** store, generator, HTTP (**NEO-146**–**NEO-154**). +- Issue-time economy re-validation (**NEO-147** / E7M4-07) — band-cap **constants** land here; **`TryIssue`** enforcement is later. +- Godot client. + +## Acceptance criteria checklist + +- [ ] Host exits on invalid contract catalog or broken encounter refs at startup. +- [ ] Registry resolves template metadata by id. +- [ ] `dotnet test` covers loader gates. + +## Technical approach + +### 1. Contract DTOs and catalog (`Game/Contracts/`) + +| Type | Role | +|------|------| +| `ContractTemplateRow` | `Id`, `DisplayName`, `ZoneDifficultyBand`, `ObjectiveKind`, `EncounterTemplateId`, `MinFactionStanding` (`FactionGateRuleRow`), `CompletionRewardBundle` (`QuestRewardBundleRow`) | +| `ContractTemplateCatalog` | Immutable `ById` map + directory metadata (mirror `FactionDefinitionCatalog`) | +| `IContractTemplateRegistry` / `ContractTemplateRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror `IEncounterDefinitionRegistry`) | + +### 2. `ContractTemplateCatalogLoader` + +Mirror `FactionDefinitionCatalogLoader` + quest bundle parsing: + +- Enumerate `*_contract_templates.json` under contracts directory; require `schemaVersion` 1 and top-level `contractTemplates` array. +- JSON Schema validate each row against `contract-template.schema.json` with nested `$ref`s (`quest-reward-bundle`, `faction-gate-rule`, grant row schemas — same registration pattern as quest loader). +- Reject duplicate template ids across files. +- Run `PrototypeE7M4ContractCatalogRules` gates (below). +- Throw `InvalidOperationException` with sorted `error:` lines on failure. + +**Loader inputs** (from resolved singletons): + +- `knownEncounterIds` — `EncounterDefinitionCatalog.ById.Keys` +- `knownItemIds` — `ItemDefinitionCatalog.ById.Keys` (or `PrototypeSlice1ItemCatalogRules.ExpectedItemIds` for parity with CI) +- `knownFactionIds` — `FactionDefinitionCatalog.ById.Keys` +- `skillDefsById` — `SkillDefinitionCatalog.ById` (for `mission_reward` allowlist) + +### 3. `PrototypeE7M4ContractCatalogRules` + +Sync to `scripts/validate_content.py`: + +| Constant / helper | Python source | +|-------------------|---------------| +| `ExpectedTemplateIds` | `PROTOTYPE_E7M4_TEMPLATE_IDS` | +| `ExpectedTemplateFreeze` | `PROTOTYPE_E7M4_TEMPLATE_FREEZE` | +| `BandCapsByDifficultyBand` | `PROTOTYPE_E7M4_BAND_CAPS` | +| `TryGetRosterGateError` | `_prototype_e7m4_template_roster_gate` | +| `TryGetFreezeGateError` | `_prototype_e7m4_template_freeze_gate` | +| `TryGetCrossRefError` | `_prototype_e7m4_cross_ref_gate` | +| `TryGetBandCapGateError` | `_prototype_e7m4_band_cap_gate` | + +Bundle normalization for freeze compare: mirror `_normalize_e7m3_completion_bundle` (item + skill XP + rep grants). + +Cross-ref gate iterates **all loaded catalog rows** (not roster-only), matching NEO-144 review fix. + +### 4. Path resolution and DI + +- Add `ContractsDirectory`, `ContractTemplateSchemaPath` to `ContentPathsOptions`. +- `ContractCatalogPathResolution` — discover `content/contracts`, resolve `contract-template.schema.json` sibling under `content/schemas/`. +- `ContractCatalogServiceCollectionExtensions.AddContractTemplateCatalog` — singleton catalog + `IContractTemplateRegistry`. +- **`Program.cs`:** register contract catalog **after** `AddQuestDefinitionCatalog`; eager-resolve `ContractTemplateCatalog` at boot (after quest catalog). +- **`InMemoryWebApplicationFactory`:** pin repo `content/contracts` via `Content:ContractsDirectory`. + +### 5. Tests + +**Loader (`ContractTemplateCatalogLoaderTests`):** + +- Happy path — repo `prototype_contract_templates.json` with real cross-ref catalogs. +- Missing contracts directory / schema. +- Duplicate template id. +- Wrong roster count or unknown id. +- Freeze table mismatch (display name, band, encounter, standing, bundle). +- Unknown `encounterTemplateId` (primary AC negative). +- Unknown bundle item/skill/faction ref; skill XP missing `mission_reward` allowlist. +- Band-cap violation (local temp fixture or inline JSON — do not commit bad catalog). + +**Registry (`ContractTemplateRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing. + +**Host boot:** `Host_ShouldResolveContractCatalogFromDi` — factory starts; `IContractTemplateRegistry` resolves `prototype_contract_clear_combat_pocket`. + +### 6. Docs + +- **`server/README.md`** — new contract catalog paragraph (config keys, gates, load order after quests). +- Optional one-line E7_M4 module CI note when implementation lands. + +### 7. Implementation order + +1. DTOs, rules, path resolution, loader, catalog, registry, DI. +2. `ContentPathsOptions`, `Program.cs` load order, `InMemoryWebApplicationFactory`. +3. Tests (loader negatives → registry → host boot). +4. `server/README.md`. +5. Run `dotnet test`. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-145-implementation-plan.md` | This plan. | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs` | Load-time contract template row | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs` | In-memory catalog | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs` | Fail-fast disk load | +| `server/NeonSprawl.Server/Game/Contracts/ContractCatalogPathResolution.cs` | Directory + schema path discovery | +| `server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs` | DI registration | +| `server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs` | Lookup contract | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs` | Registry adapter | +| `server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs` | CI-parity E7M4 gates + band caps | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs` | Loader AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs` | Registry AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs` | Repo path discovery for tests | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ContractsDirectory`, `ContractTemplateSchemaPath` | +| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve contract catalog after quest catalog | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:ContractsDirectory` for host boot tests | +| `server/README.md` | Contract catalog section (config, gates, load order) | + +## Tests + +| Layer | What | +|-------|------| +| `ContractTemplateCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/cross-ref/band-cap gates; broken encounter ref (AC) | +| `ContractTemplateRegistryTests` | Lookup by id, ordered listing | +| Host boot | `Host_ShouldResolveContractCatalogFromDi` via `InMemoryWebApplicationFactory` | +| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) | + +Manual Godot QA: **none** (server infrastructure; capstone is NEO-154). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Band-cap constants drift vs Python | Keep `PrototypeE7M4ContractCatalogRules.BandCapsByDifficultyBand` name-aligned with `PROTOTYPE_E7M4_BAND_CAPS`; add sync comment on both sides | `adopted` | +| Shared bundle cross-ref logic with quests | Extract only if duplication is large; otherwise mirror quest item/skill checks in E7M4 rules with contract-specific error prefixes | `adopted` | +| E7M4-07 issue-time validation | Loader gates suffice for E7M4-02 AC; port same `BandCapsByDifficultyBand` to `ContractEconomyValidation` in NEO-147 | `deferred` | + +## Client counterpart + +None for E7M4-02. Client contract HUD: **NEO-153** / capstone **NEO-154**.