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

194 lines
12 KiB
Markdown
Raw Permalink 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-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
- [x] Host exits on invalid contract catalog or broken encounter refs at startup.
- [x] Registry resolves template metadata by id.
- [x] `dotnet test` covers loader gates.
## Implementation reconciliation (shipped)
- **Contract catalog:** `Game/Contracts/` loader, catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` (roster, freeze, cross-ref, band caps synced to `PROTOTYPE_E7M4_*`), DI + `Program.cs` load order (after quest catalog).
- **Config:** `ContentPathsOptions` `ContractsDirectory` / `ContractTemplateSchemaPath`; `InMemoryWebApplicationFactory` pins repo `content/contracts`.
- **Tests:** `ContractTemplateCatalogLoaderTests`, `ContractTemplateRegistryTests`, host boot via `Host_ShouldResolveContractCatalogFromDi` (839 tests green).
- **Docs:** `server/README.md` contract catalog section; decomposition alignment (E7_M4, CT_M1, register tables).
- **Bruno:** `bruno/neon-sprawl-server/contract-catalog/` health smoke (startup-only; no contract HTTP in E7M4-02).
## 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**.