Merge pull request #185 from ViPro-Technologies/NEO-145-e7m4-02-server-contract-template-catalog-load-fail-fast

NEO-145: E7M4-02 server contract template catalog load (fail-fast)
pull/186/head
VinPropane 2026-06-20 19:43:56 -04:00 committed by GitHub
commit e3988874d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1685 additions and 5 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET health (contract template catalog boot NEO-145)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-145 loads content/contracts/*_contract_templates.json at startup (fail-fast). No contract HTTP API in this story — use this request to confirm the host started after catalog validation.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: contract-catalog
}

View File

@ -63,7 +63,7 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
**Quest completion bundles ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** optional **`completionRewardBundle`** on each quest row via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json) (`itemGrants` reuses [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json); `skillXpGrants` via [`quest-skill-xp-grant.schema.json`](../../../content/schemas/quest-skill-xp-grant.schema.json)). E7 Slice 2 requires bundles on all four frozen quest ids with exact freeze-table contents; cross-checks **`itemGrants[].itemId`** and **`skillXpGrants[].skillId`**; each skill XP target must allow **`mission_reward`** in **`allowedXpSourceKinds`**. **Quest completion bundles ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** optional **`completionRewardBundle`** on each quest row via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json) (`itemGrants` reuses [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json); `skillXpGrants` via [`quest-skill-xp-grant.schema.json`](../../../content/schemas/quest-skill-xp-grant.schema.json)). E7 Slice 2 requires bundles on all four frozen quest ids with exact freeze-table contents; cross-checks **`itemGrants[].itemId`** and **`skillXpGrants[].skillId`**; each skill XP target must allow **`mission_reward`** in **`allowedXpSourceKinds`**.
**Contract template catalogs ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** the same script validates each row in `content/contracts/*_contract_templates.json` against [`content/schemas/contract-template.schema.json`](../../../content/schemas/contract-template.schema.json) (`completionRewardBundle` via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json); `minFactionStanding` via [`faction-gate-rule.schema.json`](../../../content/schemas/faction-gate-rule.schema.json)), rejects **duplicate `id`** across files, cross-checks **`encounterTemplateId`** against encounter catalogs and bundle item/skill/faction refs against frozen catalogs, enforces the **frozen one-template** id set (`prototype_contract_clear_combat_pocket`) with exact freeze-table contents (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ 10, skill XP ≤ 25, rep ≤ 10 per grant row). Issue/audit shape: [`contract-seed.schema.json`](../../../content/schemas/contract-seed.schema.json) — minimal fixture smoke parse in CI (no catalog file yet). **Contract template catalogs ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** the same script validates each row in `content/contracts/*_contract_templates.json` against [`content/schemas/contract-template.schema.json`](../../../content/schemas/contract-template.schema.json) (`completionRewardBundle` via [`quest-reward-bundle.schema.json`](../../../content/schemas/quest-reward-bundle.schema.json); `minFactionStanding` via [`faction-gate-rule.schema.json`](../../../content/schemas/faction-gate-rule.schema.json)), rejects **duplicate `id`** across files, cross-checks **`encounterTemplateId`** against encounter catalogs and bundle item/skill/faction refs against frozen catalogs, enforces the **frozen one-template** id set (`prototype_contract_clear_combat_pocket`) with exact freeze-table contents (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ 10, skill XP ≤ 25, rep ≤ 10 per grant row). Issue/audit shape: [`contract-seed.schema.json`](../../../content/schemas/contract-seed.schema.json) — minimal fixture smoke parse in CI (no catalog file yet). **Server startup ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** the host mirrors the same gates via `ContractTemplateCatalogLoader` before listening (see [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145)).
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception. **Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.

View File

@ -53,6 +53,8 @@ Epic 7 **Slice 4** — `contract_issued`, `contract_complete`, reward anomalies.
**CI (NEO-144):** `content/contracts/prototype_contract_templates.json` validated by `scripts/validate_content.py` — schema, single-template roster, freeze table, encounter/item/skill/faction cross-refs, band-1 economy caps. Schemas: [`contract-template.schema.json`](../../../content/schemas/contract-template.schema.json), [`contract-seed.schema.json`](../../../content/schemas/contract-seed.schema.json). **CI (NEO-144):** `content/contracts/prototype_contract_templates.json` validated by `scripts/validate_content.py` — schema, single-template roster, freeze table, encounter/item/skill/faction cross-refs, band-1 economy caps. Schemas: [`contract-template.schema.json`](../../../content/schemas/contract-template.schema.json), [`contract-seed.schema.json`](../../../content/schemas/contract-seed.schema.json).
**Server load (NEO-145):** host fail-fast load of `content/contracts/*_contract_templates.json` via `ContractTemplateCatalogLoader` + **`IContractTemplateRegistry`** — same E7M4 gates as CI at startup; [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145).
## Source anchors ## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7.

File diff suppressed because one or more lines are too long

View File

@ -118,7 +118,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
**E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete. **E7.M3 note:** Epic 7 **Slice 3** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-133](https://linear.app/neon-sprawl/issue/NEO-133) → [NEO-143](https://linear.app/neon-sprawl/issue/NEO-143) **landed**; label **`E7.M3`**. See [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3_FactionReputationLedger.md](E7_M3_FactionReputationLedger.md). Upstream: E7.M1 **Ready**, E7.M2 **Ready**. Prototype spine: two frozen factions; operator chain grants **+15 Grid Operators**; **`prototype_quest_grid_contract`** gated at **minStanding 15**; rep grants via **`completionRewardBundle.reputationGrants`**; accept deny **`faction_gate_blocked`**. **E7M3-11 / NEO-143** client capstone landed — [`NEO-143` manual QA](../../manual-qa/NEO-143.md), [client README — End-to-end faction reputation loop (NEO-143)](../../../client/README.md#end-to-end-faction-reputation-loop-neo-143), plan [NEO-143](../../plans/NEO-143-implementation-plan.md). Epic 7 Slice 3 client capstone complete.
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress | | E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | In Progress |
**E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). **E7.M4 note:** Epic 7 **Slice 4** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. See [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4_ContractMissionGenerator.md](E7_M4_ContractMissionGenerator.md). **E7M4-01 / NEO-144** catalog + CI landed; **E7M4-02 / NEO-145** fail-fast server catalog load + **`IContractTemplateRegistry`** landed ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)). Upstream: E5.M3 **Ready**, E7.M3 **Ready**; E4.M1 live zone read deferred (content-only **`zoneDifficultyBand`** in Slice 4 v1). Prototype spine: one template **`prototype_contract_clear_combat_pocket`** → **`prototype_combat_pocket`** encounter clear → repeat **`scrap_metal_bulk` ×5** + salvage XP; economy band caps at issue. Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154).
### Epic 8 — Social / Guild ### Epic 8 — Social / Guild

View File

@ -132,8 +132,8 @@ Working backlog for **Epic 7 — Slice 4** ([contract generator](../decompositio
**Acceptance criteria** **Acceptance criteria**
- [ ] Host exits on invalid contract catalog or broken encounter refs at startup. - [x] Host exits on invalid contract catalog or broken encounter refs at startup.
- [ ] Registry resolves template metadata by id. - [x] Registry resolves template metadata by id.
**Client counterpart:** none (infrastructure-only). **Client counterpart:** none (infrastructure-only).

View File

@ -0,0 +1,193 @@
# 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**.

View File

@ -0,0 +1,68 @@
# Code review — NEO-145 (E7M4-02 server contract template catalog load)
**Date:** 2026-06-20
**Scope:** Branch `NEO-145-e7m4-02-server-contract-template-catalog-load-fail-fast` — commits `215493f`…`937412e` (3 commits)
**Base:** `main`
## Verdict
**Approve with nits**
## Summary
NEO-145 delivers fail-fast startup loading of `content/contracts/*_contract_templates.json` with CI-parity gates ported to C# (`PrototypeE7M4ContractCatalogRules`), JSON Schema validation with quest/faction bundle `$ref`s, duplicate-id rejection, and cross-ref checks against encounter, faction, frozen prototype item/skill catalogs, and the E7M2 `mission_reward` allowlist. `IContractTemplateRegistry` + DI registration follow established faction/encounter patterns; `Program.cs` registers and eagerly resolves the catalog after the quest catalog. Tests cover loader happy/negative paths, registry lookups, and host boot via `InMemoryWebApplicationFactory`; `dotnet test` (839) and `validate_content.py` both pass. Risk is low: infrastructure-only, no player-facing HTTP yet; primary failure mode is startup exit with sorted `error:` lines — appropriate for this slice.
## Documentation checked
| Document | Alignment |
|----------|-----------|
| `docs/plans/NEO-145-implementation-plan.md` | **Matches** — AC checklist complete; reconciliation section reflects shipped loader, registry, DI, tests, README. |
| `docs/plans/E7M4-pre-production-backlog.md` (E7M4-02) | **Matches** — loader, registry, cross-ref, tests, README in scope; instance store/HTTP/Godot correctly out of scope. |
| `docs/decomposition/modules/E7_M4_ContractMissionGenerator.md` | ~~**Partially matches** — freeze table and CI (NEO-144) documented; no note that server fail-fast load landed (NEO-145). Plan marked this optional.~~ **Done.** Server load (NEO-145) paragraph added. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | ~~**Partially matches** — Purpose mentions server-side smoke loading; contract paragraph still NEO-144/CI-only — should add NEO-145 server mirror.~~ **Done.** NEO-145 server startup mirror sentence added. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | ~~**Partially matches** — E7.M4 row cites E7M4-01/NEO-144 only; should mention E7M4-02 server catalog load after merge.~~ **Done.** E7M4-02 / NEO-145 row extended. |
| `docs/decomposition/modules/module_dependency_register.md` | ~~**Partially matches** — E7.M4 In Progress spine unchanged; acceptable until alignment pass, but register note could cite NEO-145.~~ **Done.** E7.M4 note cites NEO-145. |
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — read-only catalog load; no authority boundary change. |
| Full-stack epic decomposition | **N/A** — infrastructure-only; client counterpart correctly none (NEO-154 capstone). |
Register/tracking tables were **not** updated for NEO-145 in this branch — ~~**should fix** (non-blocking) per plan §6 optional module note and alignment doc pattern from NEO-144.~~ **Done.**
## Blocking issues
None.
## Suggestions
1. ~~**Update decomposition tracking for E7M4-02** — After merge, extend `documentation_and_implementation_alignment.md` E7.M4 row and optionally `module_dependency_register.md` / `E7_M4_ContractMissionGenerator.md` with one line: server fail-fast catalog load + `IContractTemplateRegistry` (NEO-145). Mirrors NEO-144 alignment follow-up.~~ **Done.**
2. ~~**CT_M1 server-load paragraph** — Add a sentence under the contract-catalog CI bullet that the host mirrors the same gates at startup via `ContractTemplateCatalogLoader` (NEO-145), matching faction/quest catalog precedent in that doc.~~ **Done.**
3. ~~**Band-cap negative through full loader** — Plan §5 lists band-cap violation via loader; shipped coverage tests `TryGetBandCapGateError` in isolation only. Consider one temp-fixture test that writes qty **11** and asserts `ContractTemplateCatalogLoader.Load` throws (same pattern as freeze/roster negatives) so the loader→rules wiring is exercised end-to-end.~~ **Done.** `Load_ShouldThrow_WhenItemQuantityExceedsPrototypeBundleLimits` exercises loader→freeze gate (qty 11 fails freeze before band-cap on the single-template roster); `TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap` covers band-cap rule directly.
4. ~~**Missing schema path test** — Plan §5 lists missing schema; only missing **directory** is tested. A temp layout with contracts dir present but schema file removed would close the gap cheaply.~~ **Done.** `Load_ShouldThrow_WhenSchemaFileMissing` added.
## Nits
- ~~Nit: `Load_ShouldThrow_WhenBundleItemIdUnknown` calls `TryGetCrossRefError` directly rather than `Load`; rename to `TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown` (or route through the loader) for clarity.~~ **Done.**
- ~~Nit: Bruno folder `bruno/neon-sprawl-server/contract-catalog/` (health smoke) is a nice addition but not listed in the plan — harmless; optional one-line in plan reconciliation if you want doc parity.~~ **Done.** Plan reconciliation notes Bruno health smoke.
- Nit: `AddContractTemplateCatalog` re-binds `ContentPathsOptions` (same as faction/quest extensions) — fine; no action needed.
## Verification
Commands run during review (all green):
```bash
dotnet test NeonSprawl.sln
python3 scripts/validate_content.py
```
Author should confirm fail-fast startup manually once (optional):
```bash
# Temporarily break encounterTemplateId in prototype_contract_templates.json
dotnet run --project server/NeonSprawl.Server # expect startup InvalidOperationException
```
Before merge: skim decomposition alignment suggestions above — functional code is merge-ready without them.

View File

@ -0,0 +1,32 @@
using NeonSprawl.Server.Game.Contracts;
namespace NeonSprawl.Server.Tests.Game.Contracts;
internal static class ContractCatalogTestPaths
{
internal static string DiscoverRepoContractsDirectory() =>
ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoContractTemplateSchemaPath() =>
ContractCatalogPathResolution.ResolveContractTemplateSchemaPath(
DiscoverRepoContractsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
internal static string DiscoverRepoRewardGrantRowSchemaPath() =>
ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoContractsDirectory());
internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() =>
ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoContractsDirectory());
internal static string DiscoverRepoQuestRewardBundleSchemaPath() =>
ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoContractsDirectory());
internal static string DiscoverRepoFactionGateRuleSchemaPath() =>
ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(DiscoverRepoContractsDirectory());
internal static string DiscoverRepoReputationGrantRowSchemaPath() =>
ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(DiscoverRepoContractsDirectory());
}

View File

@ -0,0 +1,306 @@
using System.Collections.Frozen;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public class ContractTemplateCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
private static readonly FrozenSet<string> KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds;
private static readonly IReadOnlyDictionary<string, SkillDefRow> RepoSkillDefs = LoadRepoSkillDefs();
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"contractTemplates": [
{
"id": "prototype_contract_clear_combat_pocket",
"displayName": "Clear Combat Pocket (Repeat)",
"zoneDifficultyBand": 1,
"objectiveKind": "encounter_clear",
"encounterTemplateId": "prototype_combat_pocket",
"minFactionStanding": {
"factionId": "prototype_faction_grid_operators",
"minStanding": 0
},
"completionRewardBundle": {
"itemGrants": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 15 }]
}
}
]
}
""";
private static IReadOnlyDictionary<string, SkillDefRow> LoadRepoSkillDefs()
{
var catalog = SkillDefinitionCatalogLoader.Load(
SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(),
SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(),
NullLogger.Instance);
return catalog.ById;
}
private static ContractTemplateCatalog LoadCatalog(
string contractsDir,
string contractTemplateSchemaPath,
IReadOnlySet<string>? knownEncounterIds = null,
IReadOnlySet<string>? knownFactionIds = null,
IReadOnlyDictionary<string, SkillDefRow>? skillDefsById = null)
{
return ContractTemplateCatalogLoader.Load(
contractsDir,
contractTemplateSchemaPath,
ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(),
ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(),
ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(),
ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(),
ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(),
knownEncounterIds ?? KnownEncounterIds,
knownFactionIds ?? KnownFactionIds,
skillDefsById ?? RepoSkillDefs,
NullLogger.Instance);
}
private static (string Root, string ContractsDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-contractcat-");
var contractsDir = Path.Combine(root.FullName, "content", "contracts");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(contractsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "contract-template.schema.json");
File.Copy(ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath(), schemaPath, overwrite: true);
File.Copy(ContractCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true);
File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true);
File.Copy(ContractCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true);
File.Copy(ContractCatalogTestPaths.DiscoverRepoFactionGateRuleSchemaPath(), Path.Combine(schemaDir, "faction-gate-rule.schema.json"), overwrite: true);
File.Copy(ContractCatalogTestPaths.DiscoverRepoReputationGrantRowSchemaPath(), Path.Combine(schemaDir, "reputation-grant-row.schema.json"), overwrite: true);
return (root.FullName, contractsDir, schemaPath);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
{
// Arrange
var contractsDir = ContractCatalogTestPaths.DiscoverRepoContractsDirectory();
var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath();
// Act
var catalog = LoadCatalog(contractsDir, schemaPath);
// Assert
Assert.Equal(PrototypeE7M4ContractCatalogRules.ExpectedTemplateIds.Count, catalog.DistinctTemplateCount);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var catalog = LoadCatalog(contractsDir, schemaPath);
// Assert
Assert.Equal(1, catalog.DistinctTemplateCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.ById.TryGetValue("prototype_contract_clear_combat_pocket", out var row));
Assert.NotNull(row);
Assert.Equal("Clear Combat Pocket (Repeat)", row!.DisplayName);
Assert.Equal(1, row.ZoneDifficultyBand);
}
[Fact]
public void Load_ShouldThrow_WhenContractTemplatesIsNotArray()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(contractsDir, "bad_contract_templates.json"),
"""{"schemaVersion": 1, "contractTemplates": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_contract_templates.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'contractTemplates' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(contractsDir, "a_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
File.WriteAllText(Path.Combine(contractsDir, "b_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate contract template id", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRosterGateFails()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
var emptyCatalog = """{"schemaVersion": 1, "contractTemplates": []}""";
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), emptyCatalog, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype E7M4 expects exactly contract template ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenFreezeTableDiverges()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
var badDisplay = ValidPrototypeCatalogJson.Replace("Clear Combat Pocket (Repeat)", "Wrong Name", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), badDisplay, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("displayName must be", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenEncounterTemplateIdUnknown()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
var emptyEncounters = FrozenSet<string>.Empty;
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, knownEncounterIds: emptyEncounters));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("encounterTemplateId 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in the loaded encounter catalog", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
var badItemRow = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("not_a_real_item", 5)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[badItemRow.Id] = badItemRow,
};
// Act
var error = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
rows,
KnownEncounterIds,
KnownFactionIds,
RepoSkillDefs);
// Assert
Assert.NotNull(error);
Assert.Contains("itemId 'not_a_real_item'", error, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSkillXpMissingMissionRewardAllowlist()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
var salvageNoMission = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal)
{
["salvage"] = new("salvage", "gather", "Salvage", ["activity"]),
["refine"] = RepoSkillDefs["refine"],
["intrusion"] = RepoSkillDefs["intrusion"],
};
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, skillDefsById: salvageNoMission));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenItemQuantityExceedsPrototypeBundleLimits()
{
// Arrange — qty 11 fails freeze before band-cap gate (same CI tamper path as validate_content.py)
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
var overCap = ValidPrototypeCatalogJson.Replace("\"quantity\": 5", "\"quantity\": 11", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), overCap, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M4 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaFileMissing()
{
// Arrange
var (_, contractsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(contractsDir, "prototype_contract_templates.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
File.Delete(schemaPath);
// Act
var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
Assert.Contains(schemaPath, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
var overCapRow = prototype with
{
CompletionRewardBundle = new QuestRewardBundleRow(
[new RewardGrantRow("scrap_metal_bulk", 11)],
prototype.CompletionRewardBundle.SkillXpGrants,
prototype.CompletionRewardBundle.ReputationGrants),
};
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[overCapRow.Id] = overCapRow,
};
// Act
var error = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
// Assert
Assert.NotNull(error);
Assert.Contains("quantity 11 exceeds band 1 cap 10", error, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingContractsDirectory()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-missing-contracts-" + Guid.NewGuid().ToString("N"));
var schemaPath = ContractCatalogTestPaths.DiscoverRepoContractTemplateSchemaPath();
// Act
var ex = Record.Exception(() => LoadCatalog(missingDir, schemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
}
}

View File

@ -0,0 +1,113 @@
using System.Collections.Frozen;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Contracts;
public class ContractTemplateRegistryTests
{
private static ContractTemplateRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, ContractTemplateRow> byId)
{
var catalog = new ContractTemplateCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new ContractTemplateRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrue_WhenIdExists()
{
// Arrange
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[expected.Id] = expected,
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("Clear Combat Pocket (Repeat)", def!.DisplayName);
Assert.Equal("prototype_combat_pocket", def.EncounterTemplateId);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenTemplateIdIsNull()
{
// Arrange
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[expected.Id] = expected,
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(null, out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
{
// Arrange
var expected = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[expected.Id] = expected,
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_template", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleTemplates()
{
// Arrange
var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"];
var other = prototype with { Id = "zzz_other_template" };
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
[other.Id] = other,
[prototype.Id] = prototype,
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(2, list.Count);
Assert.Equal("prototype_contract_clear_combat_pocket", list[0].Id);
Assert.Equal("zzz_other_template", list[1].Id);
}
[Fact]
public async Task Host_ShouldResolveContractCatalogFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IContractTemplateRegistry>();
var found = registry.TryGetDefinition("prototype_contract_clear_combat_pocket", out var template);
// Assert
Assert.True(found);
Assert.NotNull(template);
Assert.Equal("Clear Combat Pocket (Repeat)", template!.DisplayName);
Assert.Equal("encounter_clear", template.ObjectiveKind);
Assert.Equal("prototype_combat_pocket", template.EncounterTemplateId);
}
}

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
@ -66,6 +67,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory) var factionsDir = FactionCatalogPathResolution.TryDiscoverFactionsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "Could not discover repo content/factions from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var contractsDir = ContractCatalogPathResolution.TryDiscoverContractsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/contracts from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir); builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir); builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir); builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -77,6 +81,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:EncountersDirectory", encountersDir); builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Content:FactionsDirectory", factionsDir); builder.UseSetting("Content:FactionsDirectory", factionsDir);
builder.UseSetting("Content:QuestsDirectory", questsDir); builder.UseSetting("Content:QuestsDirectory", questsDir);
builder.UseSetting("Content:ContractsDirectory", contractsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true"); builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.UseSetting("Game:EnableQuestFixtureApi", "true"); builder.UseSetting("Game:EnableQuestFixtureApi", "true");
builder.UseSetting("Game:EnableResourceNodeFixtureApi", "true"); builder.UseSetting("Game:EnableResourceNodeFixtureApi", "true");

View File

@ -0,0 +1,80 @@
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Resolves contract catalog paths for local dev, tests, and container layouts (NEO-145).</summary>
public static class ContractCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/contracts</c> directory.</summary>
public static string? TryDiscoverContractsDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "contracts");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the contracts catalog directory.
/// Empty <paramref name="configuredContractsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveContractsDirectory(string? configuredContractsDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredContractsDirectory))
{
var discovered = TryDiscoverContractsDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:ContractsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/contracts'). " +
"Set Content:ContractsDirectory in configuration or environment (e.g. Content__ContractsDirectory).");
}
var trimmed = configuredContractsDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single contract template row (Draft 2020-12).</summary>
public static string ResolveContractTemplateSchemaPath(
string contractsDirectory,
string? configuredSchemaPath,
string contentRootPath)
{
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
{
var trimmed = configuredSchemaPath.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
return Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "contract-template.schema.json"));
}
/// <summary>Resolves JSON Schema path for a reward grant row (shared by quest bundles and contract templates).</summary>
public static string ResolveRewardGrantRowSchemaPath(string contractsDirectory) =>
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reward-grant-row.schema.json"));
/// <summary>Resolves JSON Schema path for a quest skill XP grant row.</summary>
public static string ResolveQuestSkillXpGrantSchemaPath(string contractsDirectory) =>
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json"));
/// <summary>Resolves JSON Schema path for a completion reward bundle.</summary>
public static string ResolveQuestRewardBundleSchemaPath(string contractsDirectory) =>
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-reward-bundle.schema.json"));
/// <summary>Resolves JSON Schema path for a faction gate rule row.</summary>
public static string ResolveFactionGateRuleSchemaPath(string contractsDirectory) =>
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "faction-gate-rule.schema.json"));
/// <summary>Resolves JSON Schema path for a reputation grant row.</summary>
public static string ResolveReputationGrantRowSchemaPath(string contractsDirectory) =>
Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reputation-grant-row.schema.json"));
}

View File

@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>DI registration for the fail-fast contract template catalog (NEO-145).</summary>
public static class ContractCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ContractTemplateCatalog"/> and <see cref="IContractTemplateRegistry"/> as singletons.</summary>
public static IServiceCollection AddContractTemplateCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<ContractTemplateCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var factionCatalog = sp.GetRequiredService<FactionDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Contracts.ContractCatalog");
var contractsDir = ContractCatalogPathResolution.ResolveContractsDirectory(
opts.ContractsDirectory,
hostEnv.ContentRootPath);
var contractTemplateSchemaPath = ContractCatalogPathResolution.ResolveContractTemplateSchemaPath(
contractsDir,
opts.ContractTemplateSchemaPath,
hostEnv.ContentRootPath);
var rewardGrantRowSchemaPath = ContractCatalogPathResolution.ResolveRewardGrantRowSchemaPath(contractsDir);
var questSkillXpGrantSchemaPath = ContractCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(contractsDir);
var questRewardBundleSchemaPath = ContractCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(contractsDir);
var factionGateRuleSchemaPath = ContractCatalogPathResolution.ResolveFactionGateRuleSchemaPath(contractsDir);
var reputationGrantRowSchemaPath = ContractCatalogPathResolution.ResolveReputationGrantRowSchemaPath(contractsDir);
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownFactionIds = factionCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
return ContractTemplateCatalogLoader.Load(
contractsDir,
contractTemplateSchemaPath,
rewardGrantRowSchemaPath,
questSkillXpGrantSchemaPath,
questRewardBundleSchemaPath,
factionGateRuleSchemaPath,
reputationGrantRowSchemaPath,
knownEncounterIds,
knownFactionIds,
skillCatalog.ById,
logger);
});
services.AddSingleton<IContractTemplateRegistry>(sp =>
new ContractTemplateRegistry(sp.GetRequiredService<ContractTemplateCatalog>()));
return services;
}
}

View File

@ -0,0 +1,27 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>In-memory contract template catalog loaded at startup (NEO-145). Game code should prefer <see cref="IContractTemplateRegistry"/> for lookups.</summary>
public sealed class ContractTemplateCatalog
{
public ContractTemplateCatalog(
string contractsDirectory,
IReadOnlyDictionary<string, ContractTemplateRow> byId,
int catalogJsonFileCount)
{
ContractsDirectory = contractsDirectory;
ById = new ReadOnlyDictionary<string, ContractTemplateRow>(new Dictionary<string, ContractTemplateRow>(byId, StringComparer.Ordinal));
CatalogJsonFileCount = catalogJsonFileCount;
}
/// <summary>Absolute path to the directory that was enumerated for <c>*_contract_templates.json</c> catalogs.</summary>
public string ContractsDirectory { get; }
public IReadOnlyDictionary<string, ContractTemplateRow> ById { get; }
public int DistinctTemplateCount => ById.Count;
/// <summary>Number of <c>*_contract_templates.json</c> files under <see cref="ContractsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; }
}

View File

@ -0,0 +1,353 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Loads and validates <c>content/contracts/*_contract_templates.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-145).</summary>
public static class ContractTemplateCatalogLoader
{
private static JsonSchema? _contractTemplateSchema;
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static ContractTemplateCatalog Load(
string contractsDirectory,
string contractTemplateSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
string factionGateRuleSchemaPath,
string reputationGrantRowSchemaPath,
IReadOnlySet<string> knownEncounterIds,
IReadOnlySet<string> knownFactionIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById,
ILogger logger)
{
contractsDirectory = Path.GetFullPath(contractsDirectory);
contractTemplateSchemaPath = Path.GetFullPath(contractTemplateSchemaPath);
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath);
questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath);
factionGateRuleSchemaPath = Path.GetFullPath(factionGateRuleSchemaPath);
reputationGrantRowSchemaPath = Path.GetFullPath(reputationGrantRowSchemaPath);
var errors = new List<string>();
if (!File.Exists(contractTemplateSchemaPath))
errors.Add($"error: missing schema file {contractTemplateSchemaPath}");
if (!File.Exists(rewardGrantRowSchemaPath))
errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}");
if (!File.Exists(questSkillXpGrantSchemaPath))
errors.Add($"error: missing schema file {questSkillXpGrantSchemaPath}");
if (!File.Exists(questRewardBundleSchemaPath))
errors.Add($"error: missing schema file {questRewardBundleSchemaPath}");
if (!File.Exists(factionGateRuleSchemaPath))
errors.Add($"error: missing schema file {factionGateRuleSchemaPath}");
if (!File.Exists(reputationGrantRowSchemaPath))
errors.Add($"error: missing schema file {reputationGrantRowSchemaPath}");
if (!Directory.Exists(contractsDirectory))
errors.Add($"error: missing directory {contractsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(contractsDirectory, "*_contract_templates.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_contract_templates.json files under {contractsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
EnsureContractSchemasLoaded(
contractTemplateSchemaPath,
rewardGrantRowSchemaPath,
questSkillXpGrantSchemaPath,
questRewardBundleSchemaPath,
factionGateRuleSchemaPath,
reputationGrantRowSchemaPath);
var templateSchema = _contractTemplateSchema!;
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var templateIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
JsonNode? root;
try
{
root = JsonNode.Parse(File.ReadAllText(path));
}
catch (JsonException ex)
{
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
continue;
}
if (root is not JsonObject rootObj)
{
errors.Add($"error: {path}: expected JSON object at root");
continue;
}
var schemaVersionNode = rootObj["schemaVersion"];
if (schemaVersionNode is not JsonValue schemaVersionValue ||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
schemaVersion != 1)
{
var got = schemaVersionNode?.ToJsonString() ?? "null";
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
continue;
}
var templatesNode = rootObj["contractTemplates"];
if (templatesNode is not JsonArray templatesArray)
{
errors.Add($"error: {path}: expected top-level 'contractTemplates' array");
continue;
}
for (var i = 0; i < templatesArray.Count; i++)
{
var item = templatesArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: contractTemplates[{i}] must be an object");
continue;
}
var eval = templateSchema.Evaluate(rowObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} contractTemplates[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var tid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (tid is not null && eval.IsValid)
{
if (templateIdToSourceFile.TryGetValue(tid, out var prevPath))
{
errors.Add($"error: duplicate contract template id '{tid}' in {prevPath} and {path}");
continue;
}
templateIdToSourceFile[tid] = path;
rows[tid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var roster = PrototypeE7M4ContractCatalogRules.TryGetRosterGateError(templateIdToSourceFile);
if (roster is not null)
{
errors.Add(roster);
ThrowIfAny(errors);
}
var freeze = PrototypeE7M4ContractCatalogRules.TryGetFreezeGateError(rows);
if (freeze is not null)
{
errors.Add(freeze);
ThrowIfAny(errors);
}
var crossRef = PrototypeE7M4ContractCatalogRules.TryGetCrossRefError(
rows,
knownEncounterIds,
knownFactionIds,
skillDefsById);
if (crossRef is not null)
{
errors.Add(crossRef);
ThrowIfAny(errors);
}
var bandCap = PrototypeE7M4ContractCatalogRules.TryGetBandCapGateError(rows);
if (bandCap is not null)
{
errors.Add(bandCap);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded contract template catalog from {ContractsDirectory}: {TemplateCount} template(s) across {CatalogFileCount} JSON catalog file(s).",
contractsDirectory,
rows.Count,
jsonFiles.Length);
}
return new ContractTemplateCatalog(contractsDirectory, rows, jsonFiles.Length);
}
private static ContractTemplateRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var zoneDifficultyBand = (rowObj["zoneDifficultyBand"] as JsonValue)!.GetValue<int>();
var objectiveKind = (rowObj["objectiveKind"] as JsonValue)!.GetValue<string>();
var encounterTemplateId = (rowObj["encounterTemplateId"] as JsonValue)!.GetValue<string>();
var minStandingObj = rowObj["minFactionStanding"] as JsonObject;
var factionId = (minStandingObj!["factionId"] as JsonValue)!.GetValue<string>();
var minStanding = (minStandingObj["minStanding"] as JsonValue)!.GetValue<int>();
var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject)!;
return new ContractTemplateRow(
id,
displayName,
zoneDifficultyBand,
objectiveKind,
encounterTemplateId,
new FactionGateRuleRow(factionId, minStanding),
completionRewardBundle);
}
private static QuestRewardBundleRow? ParseCompletionRewardBundle(JsonObject? bundleObj)
{
if (bundleObj is null)
return null;
var itemGrants = ParseItemGrantRows(bundleObj["itemGrants"] as JsonArray);
var skillXpGrants = ParseSkillXpGrantRows(bundleObj["skillXpGrants"] as JsonArray);
var reputationGrants = ParseReputationGrantRows(bundleObj["reputationGrants"] as JsonArray);
return new QuestRewardBundleRow(itemGrants, skillXpGrants, reputationGrants);
}
private static IReadOnlyList<ReputationGrantRow> ParseReputationGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<ReputationGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var factionId = (grantObj["factionId"] as JsonValue)!.GetValue<string>();
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
rows.Add(new ReputationGrantRow(factionId, amount));
}
return rows;
}
private static IReadOnlyList<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<RewardGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue<string>();
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue<int>();
rows.Add(new RewardGrantRow(itemId, quantity));
}
return rows;
}
private static IReadOnlyList<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<QuestSkillXpGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var skillId = (grantObj["skillId"] as JsonValue)!.GetValue<string>();
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
rows.Add(new QuestSkillXpGrantRow(skillId, amount));
}
return rows;
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, index, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} contractTemplates[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
/// <summary>Registers bundle + gate schemas in <see cref="SchemaRegistry.Global"/> once per process (NEO-145).</summary>
private static void EnsureContractSchemasLoaded(
string contractTemplateSchemaPath,
string rewardGrantRowSchemaPath,
string questSkillXpGrantSchemaPath,
string questRewardBundleSchemaPath,
string factionGateRuleSchemaPath,
string reputationGrantRowSchemaPath)
{
if (_contractTemplateSchema is not null)
return;
CatalogSchemaRegistry.GetOrRegisterFromFile(factionGateRuleSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(reputationGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath);
CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath);
_contractTemplateSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(contractTemplateSchemaPath);
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Contract template catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Adapter over <see cref="ContractTemplateCatalog"/> (NEO-145).</summary>
public sealed class ContractTemplateRegistry(ContractTemplateCatalog catalog) : IContractTemplateRegistry
{
/// <inheritdoc />
public bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition)
{
if (templateId is null)
{
definition = null;
return false;
}
if (catalog.ById.TryGetValue(templateId, out var row))
{
definition = row;
return true;
}
definition = null;
return false;
}
/// <inheritdoc />
public IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder()
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<ContractTemplateRow>(ids.Length);
foreach (var id in ids)
list.Add(catalog.ById[id]);
return list;
}
}

View File

@ -0,0 +1,13 @@
using NeonSprawl.Server.Game.Quests;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Load-time contract template row (NEO-145).</summary>
public sealed record ContractTemplateRow(
string Id,
string DisplayName,
int ZoneDifficultyBand,
string ObjectiveKind,
string EncounterTemplateId,
FactionGateRuleRow MinFactionStanding,
QuestRewardBundleRow CompletionRewardBundle);

View File

@ -0,0 +1,15 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Read-only access to validated <see cref="ContractTemplateRow"/> entries loaded at startup (<see cref="ContractTemplateCatalog"/>).
/// </summary>
public interface IContractTemplateRegistry
{
/// <summary>Attempts to resolve a contract template by stable <c>id</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition);
/// <summary>Every loaded definition, ordered by <see cref="ContractTemplateRow.Id"/> (ordinal).</summary>
IReadOnlyList<ContractTemplateRow> GetDefinitionsInIdOrder();
}

View File

@ -0,0 +1,314 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>
/// Prototype E7M4 contract template catalog gates (NEO-145), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_*</c> and <c>_prototype_e7m4_*</c> gate functions.
/// </summary>
public static class PrototypeE7M4ContractCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_TEMPLATE_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedTemplateIds = FrozenSet.ToFrozenSet(
["prototype_contract_clear_combat_pocket"],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_BAND_CAPS</c>; NEO-147 ports to issue-time validation.</summary>
public static readonly FrozenDictionary<int, BandCapPolicy> BandCapsByDifficultyBand =
new Dictionary<int, BandCapPolicy>
{
[1] = new BandCapPolicy(MaxItemQuantity: 10, MaxSkillXpAmount: 25, MaxReputationAmount: 10),
}.ToFrozenDictionary();
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M4_TEMPLATE_FREEZE</c>.</summary>
public static readonly FrozenDictionary<string, ContractTemplateRow> ExpectedTemplateFreeze =
new Dictionary<string, ContractTemplateRow>(StringComparer.Ordinal)
{
["prototype_contract_clear_combat_pocket"] = new(
"prototype_contract_clear_combat_pocket",
"Clear Combat Pocket (Repeat)",
1,
"encounter_clear",
"prototype_combat_pocket",
new FactionGateRuleRow("prototype_faction_grid_operators", 0),
new QuestRewardBundleRow(
[new RewardGrantRow("scrap_metal_bulk", 5)],
[new QuestSkillXpGrantRow("salvage", 15)],
[])),
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the prototype template roster fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetRosterGateError(IReadOnlyDictionary<string, string> templateIdToSourceFile)
{
var ids = templateIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedTemplateIds))
{
return
"error: prototype E7M4 expects exactly contract template ids " +
$"[{string.Join(", ", ExpectedTemplateIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}
return null;
}
/// <summary>Returns a human-readable error if template rows diverge from the E7M4 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetFreezeGateError(IReadOnlyDictionary<string, ContractTemplateRow> rowsById)
{
foreach (var (tid, expected) in ExpectedTemplateFreeze)
{
if (!rowsById.TryGetValue(tid, out var actual))
return $"error: missing contract template '{tid}'";
if (actual.DisplayName != expected.DisplayName)
{
return
$"error: contract template '{tid}' displayName must be '{expected.DisplayName}', got '{actual.DisplayName}'";
}
if (actual.ZoneDifficultyBand != expected.ZoneDifficultyBand)
{
return
$"error: contract template '{tid}' zoneDifficultyBand must be {expected.ZoneDifficultyBand}, got {actual.ZoneDifficultyBand}";
}
if (actual.ObjectiveKind != expected.ObjectiveKind)
{
return
$"error: contract template '{tid}' objectiveKind must be '{expected.ObjectiveKind}', got '{actual.ObjectiveKind}'";
}
if (actual.EncounterTemplateId != expected.EncounterTemplateId)
{
return
$"error: contract template '{tid}' encounterTemplateId must be '{expected.EncounterTemplateId}', got '{actual.EncounterTemplateId}'";
}
if (actual.MinFactionStanding.FactionId != expected.MinFactionStanding.FactionId ||
actual.MinFactionStanding.MinStanding != expected.MinFactionStanding.MinStanding)
{
return
$"error: contract template '{tid}' minFactionStanding must be {{ factionId: '{expected.MinFactionStanding.FactionId}', minStanding: {expected.MinFactionStanding.MinStanding} }}, " +
$"got {{ factionId: '{actual.MinFactionStanding.FactionId}', minStanding: {actual.MinFactionStanding.MinStanding} }}";
}
var actualBundle = NormalizeBundle(actual.CompletionRewardBundle);
var expectedBundle = NormalizeBundle(expected.CompletionRewardBundle);
if (!BundlesEqual(actualBundle, expectedBundle))
{
return
$"error: contract template '{tid}' completionRewardBundle must match E7M4 freeze table " +
$"(expected {FormatBundle(expectedBundle)}, got {FormatBundle(actualBundle)})";
}
}
return null;
}
/// <summary>Returns a human-readable error when cross-refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetCrossRefError(
IReadOnlyDictionary<string, ContractTemplateRow> rowsById,
IReadOnlySet<string> knownEncounterIds,
IReadOnlySet<string> knownFactionIds,
IReadOnlyDictionary<string, SkillDefRow> skillDefsById)
{
foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal))
{
var row = rowsById[tid];
if (!knownEncounterIds.Contains(row.EncounterTemplateId))
{
return
$"error: contract template '{tid}' encounterTemplateId '{row.EncounterTemplateId}' " +
"is not in the loaded encounter catalog";
}
if (!knownFactionIds.Contains(row.MinFactionStanding.FactionId))
{
return
$"error: contract template '{tid}' minFactionStanding.factionId " +
$"'{row.MinFactionStanding.FactionId}' is not in the frozen prototype faction catalog";
}
var bundle = row.CompletionRewardBundle;
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
{
var grant = bundle.ItemGrants[gi];
if (!PrototypeSlice1ItemCatalogRules.ExpectedItemIds.Contains(grant.ItemId))
{
return
$"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}]: itemId " +
$"'{grant.ItemId}' is not in the frozen prototype item catalog";
}
}
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
{
var grant = bundle.SkillXpGrants[gi];
if (!PrototypeSlice1SkillCatalogRules.ExpectedSkillIds.Contains(grant.SkillId))
{
return
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
$"'{grant.SkillId}' is not in the frozen prototype skill catalog";
}
skillDefsById.TryGetValue(grant.SkillId, out var skill);
var allowedKinds = skill?.AllowedXpSourceKinds ?? [];
if (!allowedKinds.Contains(PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind, StringComparer.Ordinal))
{
var kindsDisplay = allowedKinds.Count == 0
? "[]"
: $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]";
return
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " +
$"'{grant.SkillId}' must allow sourceKind '{PrototypeE7M2QuestCatalogRules.MissionRewardSourceKind}' " +
$"in allowedXpSourceKinds (got {kindsDisplay})";
}
}
for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++)
{
var grant = bundle.ReputationGrants[gi];
if (!knownFactionIds.Contains(grant.FactionId))
{
return
$"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}]: factionId " +
$"'{grant.FactionId}' is not in the frozen prototype faction catalog";
}
}
}
return null;
}
/// <summary>Returns a human-readable error if a template bundle exceeds band caps, otherwise <see langword="null"/>.</summary>
public static string? TryGetBandCapGateError(IReadOnlyDictionary<string, ContractTemplateRow> rowsById)
{
foreach (var tid in rowsById.Keys.Order(StringComparer.Ordinal))
{
var row = rowsById[tid];
if (!BandCapsByDifficultyBand.TryGetValue(row.ZoneDifficultyBand, out var caps))
{
return
$"error: contract template '{tid}' zoneDifficultyBand {row.ZoneDifficultyBand} has no " +
"prototype economy cap policy";
}
var bundle = row.CompletionRewardBundle;
for (var gi = 0; gi < bundle.ItemGrants.Count; gi++)
{
var grant = bundle.ItemGrants[gi];
if (grant.Quantity > caps.MaxItemQuantity)
{
return
$"error: contract template '{tid}' completionRewardBundle.itemGrants[{gi}] " +
$"quantity {grant.Quantity} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxItemQuantity}";
}
}
for (var gi = 0; gi < bundle.SkillXpGrants.Count; gi++)
{
var grant = bundle.SkillXpGrants[gi];
if (grant.Amount > caps.MaxSkillXpAmount)
{
return
$"error: contract template '{tid}' completionRewardBundle.skillXpGrants[{gi}] " +
$"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} cap {caps.MaxSkillXpAmount}";
}
}
for (var gi = 0; gi < bundle.ReputationGrants.Count; gi++)
{
var grant = bundle.ReputationGrants[gi];
if (Math.Abs(grant.Amount) > caps.MaxReputationAmount)
{
return
$"error: contract template '{tid}' completionRewardBundle.reputationGrants[{gi}] " +
$"amount {grant.Amount} exceeds band {row.ZoneDifficultyBand} rep cap {caps.MaxReputationAmount}";
}
}
}
return null;
}
private static NormalizedBundle NormalizeBundle(QuestRewardBundleRow bundle) =>
new(
bundle.ItemGrants
.OrderBy(g => g.ItemId, StringComparer.Ordinal)
.ThenBy(g => g.Quantity)
.ToArray(),
bundle.SkillXpGrants
.OrderBy(g => g.SkillId, StringComparer.Ordinal)
.ThenBy(g => g.Amount)
.ToArray(),
bundle.ReputationGrants
.OrderBy(g => g.FactionId, StringComparer.Ordinal)
.ThenBy(g => g.Amount)
.ToArray());
private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right)
{
if (left.ItemGrants.Length != right.ItemGrants.Length ||
left.SkillXpGrants.Length != right.SkillXpGrants.Length ||
left.ReputationGrants.Length != right.ReputationGrants.Length)
{
return false;
}
for (var i = 0; i < left.ItemGrants.Length; i++)
{
if (left.ItemGrants[i].ItemId != right.ItemGrants[i].ItemId ||
left.ItemGrants[i].Quantity != right.ItemGrants[i].Quantity)
{
return false;
}
}
for (var i = 0; i < left.SkillXpGrants.Length; i++)
{
if (left.SkillXpGrants[i].SkillId != right.SkillXpGrants[i].SkillId ||
left.SkillXpGrants[i].Amount != right.SkillXpGrants[i].Amount)
{
return false;
}
}
for (var i = 0; i < left.ReputationGrants.Length; i++)
{
if (left.ReputationGrants[i].FactionId != right.ReputationGrants[i].FactionId ||
left.ReputationGrants[i].Amount != right.ReputationGrants[i].Amount)
{
return false;
}
}
return true;
}
private static string FormatBundle(NormalizedBundle bundle)
{
var itemGrants = string.Join(
", ",
bundle.ItemGrants.Select(g => $"{{ itemId: '{g.ItemId}', quantity: {g.Quantity} }}"));
var skillXpGrants = string.Join(
", ",
bundle.SkillXpGrants.Select(g => $"{{ skillId: '{g.SkillId}', amount: {g.Amount} }}"));
var reputationGrants = string.Join(
", ",
bundle.ReputationGrants.Select(g => $"{{ factionId: '{g.FactionId}', amount: {g.Amount} }}"));
return
$"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}], reputationGrants: [{reputationGrants}] }}";
}
private sealed record NormalizedBundle(
RewardGrantRow[] ItemGrants,
QuestSkillXpGrantRow[] SkillXpGrants,
ReputationGrantRow[] ReputationGrants);
}
/// <summary>Prototype economy caps keyed by zone difficulty band (NEO-145).</summary>
public sealed record BandCapPolicy(int MaxItemQuantity, int MaxSkillXpAmount, int MaxReputationAmount);

View File

@ -166,4 +166,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>. /// When unset, resolved as <c>{parent of factions directory}/schemas/faction-def.schema.json</c>.
/// </summary> /// </summary>
public string? FactionDefSchemaPath { get; set; } public string? FactionDefSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/contracts</c> directory.
/// </summary>
public string? ContractsDirectory { get; set; }
/// <summary>
/// Optional override for <c>contract-template.schema.json</c>.
/// When unset, resolved as <c>{parent of contracts directory}/schemas/contract-template.schema.json</c>.
/// </summary>
public string? ContractTemplateSchemaPath { get; set; }
} }

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions; using NeonSprawl.Server.Game.Factions;
@ -32,6 +33,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
builder.Services.AddQuestDefinitionCatalog(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
builder.Services.AddContractTemplateCatalog(builder.Configuration);
builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration);
builder.Services.AddRewardDeliveryStore(); builder.Services.AddRewardDeliveryStore();
builder.Services.AddFactionStandingStores(builder.Configuration); builder.Services.AddFactionStandingStores(builder.Configuration);
@ -51,6 +53,7 @@ _ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>(); _ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>(); _ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
_ = app.Services.GetRequiredService<QuestDefinitionCatalog>(); _ = app.Services.GetRequiredService<QuestDefinitionCatalog>();
_ = app.Services.GetRequiredService<ContractTemplateCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>(); _ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>(); _ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -254,6 +254,21 @@ Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes. On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. Game code should use **`IQuestDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-114). The catalog singleton remains for fail-fast startup only; do not inject **`QuestDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as encounter/ability routes.
## Contract template catalog (`content/contracts`, NEO-145)
On startup the host loads every **`*_contract_templates.json`** under the contracts directory **after** the item, recipe, encounter, skill, faction, and quest catalogs, validates each row against **`content/schemas/contract-template.schema.json`** (with **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**, **`faction-gate-rule.schema.json`**, and **`reputation-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate template `id`** values across files, cross-checks **`encounterTemplateId`** against the loaded encounter catalog and bundle item/skill/faction refs against frozen prototype catalogs, enforces the **prototype E7M4** one-template roster and freeze table (including empty **`reputationGrants`**), and enforces **band-1 economy caps** (item qty ≤ **10**, skill XP ≤ **25**, rep ≤ **10** per grant row — same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
| Config | Meaning |
|--------|---------|
| **`Content:ContractsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/contracts`**. |
| **`Content:ContractTemplateSchemaPath`** | Optional override for **`contract-template.schema.json`**. When unset, **`{parent of contracts directory}/schemas/contract-template.schema.json`**. |
Bundle- and gate-related schemas resolve as siblings under **`{parent of contracts directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those files next to the contract template schema (NEO-145).
**Docker / CI:** include **`content/contracts`**, upstream catalogs (**`content/encounters`**, **`content/skills`**, **`content/factions`**, **`content/items`**), and contract/bundle/faction schemas in the mounted **`content/`** tree; set **`Content__ContractsDirectory`** when layout differs.
On success, **Information** logs include the resolved contracts directory path, distinct template count, and catalog file count. Game code should use **`IContractTemplateRegistry`** for lookups (`TryGetDefinition`, `GetDefinitionsInIdOrder`; NEO-145). The catalog singleton remains for fail-fast startup only; do not inject **`ContractTemplateCatalog`** in new game code.
## Quest definitions (NEO-115) ## Quest definitions (NEO-115)
**`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`. **`GET /game/world/quest-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`quests`**) backed by **`IQuestDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`prerequisiteQuestIds`**, optional **`factionGateRules`** (`factionId`, `minStanding` — omitted when the quest has no gates), and nested **`steps`** (`id`, `displayName`, **`objectives`** with flat objective fields per kind — unused keys omitted). Plan: [NEO-115 implementation plan](../../docs/plans/NEO-115-implementation-plan.md); gate projection: [NEO-140 implementation plan](../../docs/plans/NEO-140-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/quest-definitions/`.