NEO-134: add E7M3-02 faction catalog load implementation plan

Kickoff plan for fail-fast server faction catalog load with CI-parity
gates, quest faction cross-refs, and registry DI wiring.
pull/173/head
VinPropane 2026-06-15 20:03:44 -04:00
parent f248022c2d
commit edb33514c5
1 changed files with 200 additions and 0 deletions

View File

@ -0,0 +1,200 @@
# NEO-134 — E7M3-02: Server faction catalog load (fail-fast)
**Linear:** [NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)
**Branch:** `NEO-134-e7m3-server-faction-catalog-load-fail-fast`
**Backlog:** [E7M3-pre-production-backlog.md](E7M3-pre-production-backlog.md) — **E7M3-02**
**Module:** [E7_M3_FactionReputationLedger.md](../decomposition/modules/E7_M3_FactionReputationLedger.md)
**Pattern:** [NEO-125-implementation-plan.md](NEO-125-implementation-plan.md) (catalog loader + CI-parity gates); [NEO-113](NEO-113-implementation-plan.md) (skill/item catalog DI shape)
**Precursor:** [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) **`Done`** — faction schemas, `prototype_factions.json`, quest `factionGateRules` / `reputationGrants`, five-quest CI gates (**landed on `main`**)
## Goal
Fail-fast startup load of `content/factions/*_factions.json` with **CI-parity** validation; cross-check quest **`factionGateRules`** and **`completionRewardBundle.reputationGrants`** against the loaded faction catalog. Host refuses to listen when faction content or quest faction refs are invalid.
## Kickoff clarifications
**No clarifications needed.** Linear AC, [E7M3-02 backlog scope](E7M3-pre-production-backlog.md#e7m3-02--server-faction-catalog-load-fail-fast), kickoff decisions table, and NEO-125/NEO-133 precedents settle:
| Topic | Decision | Evidence |
|-------|----------|----------|
| Module folder | `server/NeonSprawl.Server/Game/Factions/` | Backlog lists this path first; contracts are `FactionDef` / `IFactionDefinitionRegistry` |
| CI parity | Full E7M3 faction + quest faction gates on server | Backlog “CI-parity validation”; NEO-125 full E7M2 gate precedent |
| Quest DTO parse | Parse `factionGateRules` + `reputationGrants` onto typed rows now | NEO-125 parsed `completionRewardBundle` at load; NEO-137 gate eval needs rows |
| Load order | Faction catalog **before** quest catalog | Quest cross-ref gate needs `knownFactionIds` from faction registry |
| E7M2 bundle freeze | Keep existing item/skill freeze; **add** E7M3 freeze including rep | CI runs both `_prototype_e7m2_*` and `_prototype_e7m3_completion_bundle_freeze_gate` |
| `PrototypeE7M1QuestCatalogRules` rename | **Defer** full type rename | NEO-133 partial fix (error strings); rename is churn across many call sites — not in E7M3-02 AC |
| Standing store / apply / HTTP / Godot | **Out of scope** | Backlog E7M3-02; NEO-135+ |
## Scope and out-of-scope
**In scope (from Linear + backlog):**
- Faction catalog loader, catalog type, `IFactionDefinitionRegistry`, DI registration.
- `PrototypeE7M3FactionCatalogRules` — roster, freeze table, standing-band ordering (mirrors `scripts/validate_content.py` `PROTOTYPE_E7M3_FACTION_*` + `_prototype_e7m3_faction_*` gates).
- Quest loader extensions: parse `FactionGateRuleRow`, `ReputationGrantRow`; E7M3 quest gates — faction cross-ref, E7M3 completion bundle freeze (item + skill + rep), grid-contract shape.
- `ContentPathsOptions` + path resolution for `content/factions/`.
- Unit tests (AAA) for faction loader happy path + broken refs; quest loader faction cross-ref negatives; host boot resolves faction + quest catalogs.
- `server/README.md` faction catalog section.
**Out of scope:**
- Per-player standing store, reputation apply, gate evaluation on accept, HTTP projection (NEO-135NEO-139).
- Godot client (**client counterpart:** none — infrastructure-only).
## Acceptance criteria checklist
- [ ] Host exits on invalid faction catalog or broken quest faction refs at startup.
- [ ] Registry resolves faction metadata by id.
- [ ] `dotnet test` covers loader gates.
## Technical approach
### 1. Faction DTOs and catalog (`Game/Factions/`)
| Type | Role |
|------|------|
| `FactionDefRow` | `Id`, `DisplayName`, `MinStanding`, `MaxStanding`, `NeutralStanding` |
| `FactionGateRuleRow` | `FactionId`, `MinStanding` (quest-side; lives in `Game/Quests/` or shared — prefer `Game/Quests/` next to quest rows) |
| `ReputationGrantRow` | `FactionId`, `Amount` (quest bundle row; `Game/Quests/`) |
| `FactionDefinitionCatalog` | Immutable `ById` map + directory metadata (mirror `SkillDefinitionCatalog`) |
| `IFactionDefinitionRegistry` / `FactionDefinitionRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror item registry) |
### 2. `FactionDefinitionCatalogLoader`
Mirror `SkillDefinitionCatalogLoader`:
- Enumerate `*_factions.json` under factions directory; require `schemaVersion` 1 and top-level `factions` array.
- JSON Schema validate each row against `faction-def.schema.json`.
- Reject duplicate faction ids across files.
- Run `PrototypeE7M3FactionCatalogRules`: roster (exactly two ids), freeze table (display names + standing band), standing-band ordering (`minStanding <= neutralStanding <= maxStanding`).
- Throw `InvalidOperationException` with sorted `error:` lines on failure.
### 3. `PrototypeE7M3FactionCatalogRules`
Sync to `scripts/validate_content.py`:
| Constant / helper | Python source |
|-------------------|---------------|
| `ExpectedFactionIds` | `PROTOTYPE_E7M3_FACTION_IDS` |
| `ExpectedFactionFreeze` | `PROTOTYPE_E7M3_FACTION_FREEZE` |
| `TryGetRosterGateError` | `_prototype_e7m3_faction_roster_gate` |
| `TryGetFreezeGateError` | `_prototype_e7m3_faction_freeze_gate` |
| `TryGetStandingBandGateError` | `_prototype_e7m3_faction_standing_band_gate` |
### 4. `PrototypeE7M3QuestFactionRules` (`Game/Quests/`)
Sync quest-side E7M3 gates:
| Helper | Python source |
|--------|---------------|
| `TryGetFactionCrossRefError(rows, knownFactionIds)` | `_prototype_e7m3_faction_cross_ref_gate` |
| `TryGetCompletionBundleFreezeError(rows)` | `_prototype_e7m3_completion_bundle_freeze_gate` (uses `PROTOTYPE_E7M3_COMPLETION_BUNDLES` incl. rep) |
| `TryGetGridContractShapeError(rows)` | `_prototype_e7m3_grid_contract_shape_gate` |
Extend **`QuestDefRow`** with optional `FactionGateRules`. Extend **`QuestRewardBundleRow`** with `ReputationGrants`. Update **`ParseRow`** / **`ParseCompletionRewardBundle`** in `QuestDefinitionCatalogLoader`.
Run E7M3 quest gates **after** E7M2 bundle gates, passing `knownFactionIds` from faction catalog into cross-ref.
### 5. Path resolution and DI
- Add `FactionsDirectory`, `FactionDefSchemaPath` to `ContentPathsOptions`.
- `FactionCatalogPathResolution` — discover `content/factions`, resolve `faction-def.schema.json` sibling under `content/schemas/`.
- `FactionCatalogServiceCollectionExtensions.AddFactionDefinitionCatalog` — singleton catalog + registry.
- **`Program.cs`:** register faction catalog **after** skill catalog, **before** quest catalog; eager-resolve `FactionDefinitionCatalog` at boot.
- **`QuestCatalogServiceCollectionExtensions`:** inject `FactionDefinitionCatalog`; pass `factionCatalog.ById.Keys` into quest loader.
### 6. Tests
**Faction loader (`FactionDefinitionCatalogLoaderTests`):**
- Happy path — repo `prototype_factions.json`.
- Missing factions directory / schema.
- Duplicate faction id.
- Wrong roster count or unknown id.
- Freeze table mismatch (display name or standing).
- Invalid standing band ordering.
**Quest loader extensions (`QuestDefinitionCatalogLoaderTests`):**
- Unknown `factionId` in `factionGateRules`.
- Unknown `factionId` in `reputationGrants`.
- E7M3 bundle freeze mismatch (operator-chain rep row).
- Grid-contract shape violation (wrong `minStanding` or objective).
- Existing repo prototype load + `Host_ShouldResolveQuestCatalogFromDi` still pass.
**Registry (`FactionDefinitionRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing.
### 7. Docs
- **`server/README.md`** — new faction catalog paragraph (config keys, gates, load order before quests).
- Optional one-line E7_M3 module status when implementation lands.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Factions/FactionDefRow.cs` | Load-time faction row |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalog.cs` | In-memory catalog |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionCatalogLoader.cs` | Fail-fast disk load |
| `server/NeonSprawl.Server/Game/Factions/FactionCatalogPathResolution.cs` | Directory + schema path discovery |
| `server/NeonSprawl.Server/Game/Factions/FactionCatalogServiceCollectionExtensions.cs` | DI registration |
| `server/NeonSprawl.Server/Game/Factions/IFactionDefinitionRegistry.cs` | Lookup contract |
| `server/NeonSprawl.Server/Game/Factions/FactionDefinitionRegistry.cs` | Registry adapter |
| `server/NeonSprawl.Server/Game/Factions/PrototypeE7M3FactionCatalogRules.cs` | CI-parity faction gates |
| `server/NeonSprawl.Server/Game/Quests/FactionGateRuleRow.cs` | Quest gate row |
| `server/NeonSprawl.Server/Game/Quests/ReputationGrantRow.cs` | Bundle rep grant row |
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M3QuestFactionRules.cs` | Quest-side E7M3 faction gates |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionCatalogLoaderTests.cs` | Loader AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionDefinitionRegistryTests.cs` | Registry AAA tests |
| `server/NeonSprawl.Server.Tests/Game/Factions/FactionCatalogTestPaths.cs` | Repo path discovery for tests |
## Files to modify
| Path | Change |
|------|--------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `FactionsDirectory`, `FactionDefSchemaPath` |
| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Optional `FactionGateRules` property |
| `server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs` | Add `ReputationGrants` list |
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Parse faction fields; accept `knownFactionIds`; run E7M3 quest faction gates |
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Inject faction catalog into quest loader |
| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve faction catalog before quests |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | E7M3 negative cases; extend `LoadCatalog` helper with faction ids |
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Faction directory/schema discovery helpers |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Ensure host boot resolves faction catalog if needed |
| `server/README.md` | Faction catalog section |
## Tests
| Layer | What |
|-------|------|
| `FactionDefinitionCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/standing-band gates |
| `FactionDefinitionRegistryTests` | Lookup by id, ordered listing |
| `QuestDefinitionCatalogLoaderTests` | Faction cross-ref, E7M3 bundle freeze, grid-contract shape; repo prototype + host boot |
| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) |
Manual QA: **none** (server infrastructure; Godot capstone is NEO-143).
## Implementation order
1. Faction DTOs, rules, path resolution, loader, catalog, registry, DI.
2. Quest DTO extensions + parse + `PrototypeE7M3QuestFactionRules` + wire into quest loader.
3. `Program.cs` load order + `ContentPathsOptions`.
4. Tests (faction loader → quest negatives → host boot).
5. `server/README.md`.
6. Run `dotnet test` on server project.
## Open questions / risks
| Item | Agent recommendation | Status |
|------|---------------------|--------|
| Rename `PrototypeE7M1QuestCatalogRules``PrototypeE7M3QuestCatalogRules` | Defer — not required for AC; large rename across tests and call sites | `deferred` |
| E7M2 vs E7M3 bundle freeze overlap | Keep both gates (CI does); E7M2 item/skill only, E7M3 adds rep rows | `adopted` |
| Quest loader already registers `faction-gate-rule` / `reputation-grant-row` schemas (NEO-133) | Reuse; no schema registration changes needed | `adopted` |
## Client counterpart
None for E7M3-02. Client faction HUD: **NEO-142** / capstone **NEO-143**.
## Blocks
**NEO-135** (faction standing store) is blocked until faction catalog load lands.