208 lines
12 KiB
Markdown
208 lines
12 KiB
Markdown
# 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-135–NEO-139).
|
||
- Godot client (**client counterpart:** none — infrastructure-only).
|
||
|
||
## Acceptance criteria checklist
|
||
|
||
- [x] Host exits on invalid faction catalog or broken quest faction refs at startup.
|
||
- [x] Registry resolves faction metadata by id.
|
||
- [x] `dotnet test` covers loader gates.
|
||
|
||
## Implementation reconciliation (shipped)
|
||
|
||
- **Faction catalog:** `Game/Factions/` loader, catalog, `IFactionDefinitionRegistry`, `PrototypeE7M3FactionCatalogRules`, DI + `Program.cs` load order (after skills, before quests).
|
||
- **Quest extensions:** `FactionGateRuleRow`, `ReputationGrantRow`, `PrototypeE7M3QuestFactionRules` (cross-ref, E7M3 bundle freeze, grid-contract shape); quest loader parses faction fields and runs E7M3 gates.
|
||
- **Tests:** `FactionDefinitionCatalogLoaderTests`, `FactionDefinitionRegistryTests`, four new quest-loader negative cases; host boot resolves faction + quest catalogs (`746` tests green).
|
||
- **Docs:** `server/README.md` faction catalog section; quest catalog paragraph updated for E7M3 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.
|