From 215493f63bd130ef052f311af39a506dffa8e54e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 20 Jun 2026 19:26:34 -0400 Subject: [PATCH 1/5] NEO-145: add E7M4-02 contract catalog load implementation plan. Kickoff plan for fail-fast server loader, IContractTemplateRegistry, and CI-parity E7M4 gates following NEO-144 content landing. --- docs/plans/NEO-145-implementation-plan.md | 185 ++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/plans/NEO-145-implementation-plan.md diff --git a/docs/plans/NEO-145-implementation-plan.md b/docs/plans/NEO-145-implementation-plan.md new file mode 100644 index 0000000..140b34b --- /dev/null +++ b/docs/plans/NEO-145-implementation-plan.md @@ -0,0 +1,185 @@ +# NEO-145 — E7M4-02: Server contract template catalog load (fail-fast) + +**Linear:** [NEO-145](https://linear.app/neon-sprawl/issue/NEO-145) +**Branch:** `NEO-145-e7m4-02-server-contract-template-catalog-load-fail-fast` +**Backlog:** [E7M4-pre-production-backlog.md](E7M4-pre-production-backlog.md) — **E7M4-02** +**Module:** [E7_M4_ContractMissionGenerator.md](../decomposition/modules/E7_M4_ContractMissionGenerator.md) +**Pattern:** [NEO-134-implementation-plan.md](NEO-134-implementation-plan.md) (faction catalog load + CI-parity gates); [NEO-113-implementation-plan.md](NEO-113-implementation-plan.md) (quest catalog cross-ref deps); [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md) (encounter registry + DI) +**Precursor:** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) **`Done`** — `contract-template` + `contract-seed` schemas, `prototype_contract_templates.json`, E7M4 CI gates (**merged to `main`**) +**Blocks:** [NEO-146](https://linear.app/neon-sprawl/issue/NEO-146) (contract instance + outcome stores) +**Client counterpart:** none (infrastructure-only); playable loop is **NEO-154** (E7M4-11) + +## Goal + +Fail-fast startup load of `content/contracts/*_contract_templates.json` with **CI-parity** validation; cross-check **`encounterTemplateId`** and bundle refs against loaded catalogs. Host refuses to listen when contract content is missing, malformed, or fails prototype E7M4 gates. **`IContractTemplateRegistry`** resolves template metadata by id for E7M4-04+. + +## Kickoff clarifications + +**No blocking decisions needed.** Linear AC, [E7M4-02 backlog scope](E7M4-pre-production-backlog.md#e7m4-02--server-contract-template-catalog-load-fail-fast), [E7M4 kickoff decisions table](E7M4-pre-production-backlog.md#kickoff-decisions-decomposition-defaults), NEO-144 shipped CI gates, and NEO-134/NEO-113 precedents settle: + +| Topic | Decision | Evidence | +|-------|----------|----------| +| CI parity scope | Full E7M4 gates on server (schema, roster, freeze, cross-ref, band caps) | Backlog “CI-parity validation”; NEO-134 full E7M3 gate precedent | +| Registry in E7M4-02 | Ship **`IContractTemplateRegistry`** + DI now | Backlog in-scope list; unlike E5M3-02/03 split, E7M4-02 explicitly includes registry | +| Runtime validation | In-process **C#** mirroring `scripts/validate_content.py` | NEO-51/134 precedent; no Python on server images | +| Cross-ref deps | Encounter, item, skill, faction catalogs at load | Python `_prototype_e7m4_cross_ref_gate`; quest E7M2 `mission_reward` allowlist on skill XP | +| Load order | After item, skill, faction, encounter, quest catalogs | All cross-ref sources resolved; contracts do not affect quest load | +| Band caps | Port **`PROTOTYPE_E7M4_BAND_CAPS`** to C# in loader gates now | NEO-144 review + plan open question; E7M4-07 reuses same constants at issue time | +| Bundle DTO reuse | Reuse **`QuestRewardBundleRow`**, **`FactionGateRuleRow`** from `Game/Quests/` | Same `$ref`s as quest catalog; avoids duplicate grant row types | +| Instance store / generator / HTTP / Godot | **Out of scope** | Backlog E7M4-02; NEO-146+ | + +## Scope and out-of-scope + +**In scope (from Linear + backlog):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Contracts/`. +- **`IContractTemplateRegistry`** + DI registration. +- Cross-check **`encounterTemplateId`** vs loaded encounter catalog; bundle item/skill/faction refs + **`mission_reward`** allowlist (CI parity). +- **`PrototypeE7M4ContractCatalogRules`** — roster, freeze, cross-ref, band-cap gates synced to `scripts/validate_content.py` `PROTOTYPE_E7M4_*` + `_prototype_e7m4_*`. +- `ContentPathsOptions` + path resolution for `content/contracts/`. +- Unit tests (AAA) for loader happy path and failure modes (broken encounter ref, roster/freeze/band-cap); host boot resolves contract catalog. +- `server/README.md` contract catalog section. + +**Out of scope (from Linear + backlog):** + +- Per-player **`ContractInstance`** store, generator, HTTP (**NEO-146**–**NEO-154**). +- Issue-time economy re-validation (**NEO-147** / E7M4-07) — band-cap **constants** land here; **`TryIssue`** enforcement is later. +- Godot client. + +## Acceptance criteria checklist + +- [ ] Host exits on invalid contract catalog or broken encounter refs at startup. +- [ ] Registry resolves template metadata by id. +- [ ] `dotnet test` covers loader gates. + +## Technical approach + +### 1. Contract DTOs and catalog (`Game/Contracts/`) + +| Type | Role | +|------|------| +| `ContractTemplateRow` | `Id`, `DisplayName`, `ZoneDifficultyBand`, `ObjectiveKind`, `EncounterTemplateId`, `MinFactionStanding` (`FactionGateRuleRow`), `CompletionRewardBundle` (`QuestRewardBundleRow`) | +| `ContractTemplateCatalog` | Immutable `ById` map + directory metadata (mirror `FactionDefinitionCatalog`) | +| `IContractTemplateRegistry` / `ContractTemplateRegistry` | `TryGetDefinition`, `GetDefinitionsInIdOrder` (mirror `IEncounterDefinitionRegistry`) | + +### 2. `ContractTemplateCatalogLoader` + +Mirror `FactionDefinitionCatalogLoader` + quest bundle parsing: + +- Enumerate `*_contract_templates.json` under contracts directory; require `schemaVersion` 1 and top-level `contractTemplates` array. +- JSON Schema validate each row against `contract-template.schema.json` with nested `$ref`s (`quest-reward-bundle`, `faction-gate-rule`, grant row schemas — same registration pattern as quest loader). +- Reject duplicate template ids across files. +- Run `PrototypeE7M4ContractCatalogRules` gates (below). +- Throw `InvalidOperationException` with sorted `error:` lines on failure. + +**Loader inputs** (from resolved singletons): + +- `knownEncounterIds` — `EncounterDefinitionCatalog.ById.Keys` +- `knownItemIds` — `ItemDefinitionCatalog.ById.Keys` (or `PrototypeSlice1ItemCatalogRules.ExpectedItemIds` for parity with CI) +- `knownFactionIds` — `FactionDefinitionCatalog.ById.Keys` +- `skillDefsById` — `SkillDefinitionCatalog.ById` (for `mission_reward` allowlist) + +### 3. `PrototypeE7M4ContractCatalogRules` + +Sync to `scripts/validate_content.py`: + +| Constant / helper | Python source | +|-------------------|---------------| +| `ExpectedTemplateIds` | `PROTOTYPE_E7M4_TEMPLATE_IDS` | +| `ExpectedTemplateFreeze` | `PROTOTYPE_E7M4_TEMPLATE_FREEZE` | +| `BandCapsByDifficultyBand` | `PROTOTYPE_E7M4_BAND_CAPS` | +| `TryGetRosterGateError` | `_prototype_e7m4_template_roster_gate` | +| `TryGetFreezeGateError` | `_prototype_e7m4_template_freeze_gate` | +| `TryGetCrossRefError` | `_prototype_e7m4_cross_ref_gate` | +| `TryGetBandCapGateError` | `_prototype_e7m4_band_cap_gate` | + +Bundle normalization for freeze compare: mirror `_normalize_e7m3_completion_bundle` (item + skill XP + rep grants). + +Cross-ref gate iterates **all loaded catalog rows** (not roster-only), matching NEO-144 review fix. + +### 4. Path resolution and DI + +- Add `ContractsDirectory`, `ContractTemplateSchemaPath` to `ContentPathsOptions`. +- `ContractCatalogPathResolution` — discover `content/contracts`, resolve `contract-template.schema.json` sibling under `content/schemas/`. +- `ContractCatalogServiceCollectionExtensions.AddContractTemplateCatalog` — singleton catalog + `IContractTemplateRegistry`. +- **`Program.cs`:** register contract catalog **after** `AddQuestDefinitionCatalog`; eager-resolve `ContractTemplateCatalog` at boot (after quest catalog). +- **`InMemoryWebApplicationFactory`:** pin repo `content/contracts` via `Content:ContractsDirectory`. + +### 5. Tests + +**Loader (`ContractTemplateCatalogLoaderTests`):** + +- Happy path — repo `prototype_contract_templates.json` with real cross-ref catalogs. +- Missing contracts directory / schema. +- Duplicate template id. +- Wrong roster count or unknown id. +- Freeze table mismatch (display name, band, encounter, standing, bundle). +- Unknown `encounterTemplateId` (primary AC negative). +- Unknown bundle item/skill/faction ref; skill XP missing `mission_reward` allowlist. +- Band-cap violation (local temp fixture or inline JSON — do not commit bad catalog). + +**Registry (`ContractTemplateRegistryTests`):** `TryGetDefinition` hit/miss; id-order listing. + +**Host boot:** `Host_ShouldResolveContractCatalogFromDi` — factory starts; `IContractTemplateRegistry` resolves `prototype_contract_clear_combat_pocket`. + +### 6. Docs + +- **`server/README.md`** — new contract catalog paragraph (config keys, gates, load order after quests). +- Optional one-line E7_M4 module CI note when implementation lands. + +### 7. Implementation order + +1. DTOs, rules, path resolution, loader, catalog, registry, DI. +2. `ContentPathsOptions`, `Program.cs` load order, `InMemoryWebApplicationFactory`. +3. Tests (loader negatives → registry → host boot). +4. `server/README.md`. +5. Run `dotnet test`. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-145-implementation-plan.md` | This plan. | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs` | Load-time contract template row | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs` | In-memory catalog | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs` | Fail-fast disk load | +| `server/NeonSprawl.Server/Game/Contracts/ContractCatalogPathResolution.cs` | Directory + schema path discovery | +| `server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs` | DI registration | +| `server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs` | Lookup contract | +| `server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs` | Registry adapter | +| `server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs` | CI-parity E7M4 gates + band caps | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs` | Loader AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs` | Registry AAA tests | +| `server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs` | Repo path discovery for tests | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ContractsDirectory`, `ContractTemplateSchemaPath` | +| `server/NeonSprawl.Server/Program.cs` | Register + eager-resolve contract catalog after quest catalog | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:ContractsDirectory` for host boot tests | +| `server/README.md` | Contract catalog section (config, gates, load order) | + +## Tests + +| Layer | What | +|-------|------| +| `ContractTemplateCatalogLoaderTests` | Happy path, missing paths, duplicate id, roster/freeze/cross-ref/band-cap gates; broken encounter ref (AC) | +| `ContractTemplateRegistryTests` | Lookup by id, ordered listing | +| Host boot | `Host_ShouldResolveContractCatalogFromDi` via `InMemoryWebApplicationFactory` | +| CI | Existing `dotnet test` + `validate_content.py` (no Python changes expected) | + +Manual Godot QA: **none** (server infrastructure; capstone is NEO-154). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| Band-cap constants drift vs Python | Keep `PrototypeE7M4ContractCatalogRules.BandCapsByDifficultyBand` name-aligned with `PROTOTYPE_E7M4_BAND_CAPS`; add sync comment on both sides | `adopted` | +| Shared bundle cross-ref logic with quests | Extract only if duplication is large; otherwise mirror quest item/skill checks in E7M4 rules with contract-specific error prefixes | `adopted` | +| E7M4-07 issue-time validation | Loader gates suffice for E7M4-02 AC; port same `BandCapsByDifficultyBand` to `ContractEconomyValidation` in NEO-147 | `deferred` | + +## Client counterpart + +None for E7M4-02. Client contract HUD: **NEO-153** / capstone **NEO-154**. From 5ab5022704bc9138878c5b18714ee8bed63491ed Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 20 Jun 2026 19:31:54 -0400 Subject: [PATCH 2/5] NEO-145: add fail-fast contract template catalog load and registry. Startup loader mirrors validate_content.py E7M4 gates (roster, freeze, cross-ref, band caps); IContractTemplateRegistry resolves templates by id. Bruno health smoke confirms host boot after catalog validation. --- ...h after contract template catalog load.bru | 25 ++ .../contract-catalog/folder.bru | 3 + .../Contracts/ContractCatalogTestPaths.cs | 32 ++ .../ContractTemplateCatalogLoaderTests.cs | 277 ++++++++++++++ .../ContractTemplateRegistryTests.cs | 113 ++++++ .../InMemoryWebApplicationFactory.cs | 5 + .../ContractCatalogPathResolution.cs | 80 ++++ ...tractCatalogServiceCollectionExtensions.cs | 64 ++++ .../Game/Contracts/ContractTemplateCatalog.cs | 27 ++ .../ContractTemplateCatalogLoader.cs | 353 ++++++++++++++++++ .../Contracts/ContractTemplateRegistry.cs | 37 ++ .../Game/Contracts/ContractTemplateRow.cs | 13 + .../Contracts/IContractTemplateRegistry.cs | 15 + .../PrototypeE7M4ContractCatalogRules.cs | 314 ++++++++++++++++ .../Game/Skills/ContentPathsOptions.cs | 12 + server/NeonSprawl.Server/Program.cs | 3 + server/README.md | 15 + 17 files changed, 1388 insertions(+) create mode 100644 bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru create mode 100644 bruno/neon-sprawl-server/contract-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs diff --git a/bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru b/bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru new file mode 100644 index 0000000..06a49c7 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-catalog/Health after contract template catalog load.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/contract-catalog/folder.bru b/bruno/neon-sprawl-server/contract-catalog/folder.bru new file mode 100644 index 0000000..43cced1 --- /dev/null +++ b/bruno/neon-sprawl-server/contract-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: contract-catalog +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs new file mode 100644 index 0000000..b047c25 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractCatalogTestPaths.cs @@ -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()); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs new file mode 100644 index 0000000..8222ad6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs @@ -0,0 +1,277 @@ +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 KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds; + + private static readonly FrozenSet KnownFactionIds = PrototypeE7M3FactionCatalogRules.ExpectedFactionIds; + + private static readonly IReadOnlyDictionary 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 LoadRepoSkillDefs() + { + var catalog = SkillDefinitionCatalogLoader.Load( + SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(), + SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), + NullLogger.Instance); + return catalog.ById; + } + + private static ContractTemplateCatalog LoadCatalog( + string contractsDir, + string contractTemplateSchemaPath, + IReadOnlySet? knownEncounterIds = null, + IReadOnlySet? knownFactionIds = null, + IReadOnlyDictionary? 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(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(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(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(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.Empty; + // Act + var ex = Record.Exception(() => LoadCatalog(contractsDir, schemaPath, knownEncounterIds: emptyEncounters)); + // Assert + var ioe = Assert.IsType(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 Load_ShouldThrow_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(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(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(ex); + Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", 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(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(ex); + Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs new file mode 100644 index 0000000..89fc36c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateRegistryTests.cs @@ -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 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(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(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(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(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(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 4f9a577..cd8fb67 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; @@ -66,6 +67,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactoryResolves contract catalog paths for local dev, tests, and container layouts (NEO-145). +public static class ContractCatalogPathResolution +{ + /// Walks and parents for an existing content/contracts directory. + 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; + } + + /// + /// Resolves the contracts catalog directory. + /// Empty triggers discovery from . + /// + 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)); + } + + /// Resolves JSON Schema path for a single contract template row (Draft 2020-12). + 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")); + } + + /// Resolves JSON Schema path for a reward grant row (shared by quest bundles and contract templates). + public static string ResolveRewardGrantRowSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reward-grant-row.schema.json")); + + /// Resolves JSON Schema path for a quest skill XP grant row. + public static string ResolveQuestSkillXpGrantSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json")); + + /// Resolves JSON Schema path for a completion reward bundle. + public static string ResolveQuestRewardBundleSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "quest-reward-bundle.schema.json")); + + /// Resolves JSON Schema path for a faction gate rule row. + public static string ResolveFactionGateRuleSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "faction-gate-rule.schema.json")); + + /// Resolves JSON Schema path for a reputation grant row. + public static string ResolveReputationGrantRowSchemaPath(string contractsDirectory) => + Path.GetFullPath(Path.Combine(contractsDirectory, "..", "schemas", "reputation-grant-row.schema.json")); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..813bb8a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractCatalogServiceCollectionExtensions.cs @@ -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; + +/// DI registration for the fail-fast contract template catalog (NEO-145). +public static class ContractCatalogServiceCollectionExtensions +{ + /// Binds and registers and as singletons. + public static IServiceCollection AddContractTemplateCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var encounterCatalog = sp.GetRequiredService(); + var skillCatalog = sp.GetRequiredService(); + var factionCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .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(sp => + new ContractTemplateRegistry(sp.GetRequiredService())); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs new file mode 100644 index 0000000..fdbc861 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalog.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Contracts; + +/// In-memory contract template catalog loaded at startup (NEO-145). Game code should prefer for lookups. +public sealed class ContractTemplateCatalog +{ + public ContractTemplateCatalog( + string contractsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) + { + ContractsDirectory = contractsDirectory; + ById = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory that was enumerated for *_contract_templates.json catalogs. + public string ContractsDirectory { get; } + + public IReadOnlyDictionary ById { get; } + + public int DistinctTemplateCount => ById.Count; + + /// Number of *_contract_templates.json files under . + public int CatalogJsonFileCount { get; } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs new file mode 100644 index 0000000..52126e8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateCatalogLoader.cs @@ -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; + +/// Loads and validates content/contracts/*_contract_templates.json using the same rules as scripts/validate_content.py (NEO-145). +public static class ContractTemplateCatalogLoader +{ + private static JsonSchema? _contractTemplateSchema; + + /// Loads catalogs from disk or throws with actionable messages. + public static ContractTemplateCatalog Load( + string contractsDirectory, + string contractTemplateSchemaPath, + string rewardGrantRowSchemaPath, + string questSkillXpGrantSchemaPath, + string questRewardBundleSchemaPath, + string factionGateRuleSchemaPath, + string reputationGrantRowSchemaPath, + IReadOnlySet knownEncounterIds, + IReadOnlySet knownFactionIds, + IReadOnlyDictionary 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(); + + 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(StringComparer.Ordinal); + var rows = new Dictionary(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(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(); + 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(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var zoneDifficultyBand = (rowObj["zoneDifficultyBand"] as JsonValue)!.GetValue(); + var objectiveKind = (rowObj["objectiveKind"] as JsonValue)!.GetValue(); + var encounterTemplateId = (rowObj["encounterTemplateId"] as JsonValue)!.GetValue(); + var minStandingObj = rowObj["minFactionStanding"] as JsonObject; + var factionId = (minStandingObj!["factionId"] as JsonValue)!.GetValue(); + var minStanding = (minStandingObj["minStanding"] as JsonValue)!.GetValue(); + 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 ParseReputationGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var factionId = (grantObj["factionId"] as JsonValue)!.GetValue(); + var amount = (grantObj["amount"] as JsonValue)!.GetValue(); + rows.Add(new ReputationGrantRow(factionId, amount)); + } + + return rows; + } + + private static IReadOnlyList ParseItemGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var itemId = (grantObj["itemId"] as JsonValue)!.GetValue(); + var quantity = (grantObj["quantity"] as JsonValue)!.GetValue(); + rows.Add(new RewardGrantRow(itemId, quantity)); + } + + return rows; + } + + private static IReadOnlyList ParseSkillXpGrantRows(JsonArray? array) + { + if (array is null || array.Count == 0) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var skillId = (grantObj["skillId"] as JsonValue)!.GetValue(); + var amount = (grantObj["amount"] as JsonValue)!.GetValue(); + rows.Add(new QuestSkillXpGrantRow(skillId, amount)); + } + + return rows; + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List 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}"); + } + } + + /// Registers bundle + gate schemas in once per process (NEO-145). + 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 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()); + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs new file mode 100644 index 0000000..b9056f7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRegistry.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Adapter over (NEO-145). +public sealed class ContractTemplateRegistry(ContractTemplateCatalog catalog) : IContractTemplateRegistry +{ + /// + 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; + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + list.Add(catalog.ById[id]); + + return list; + } +} diff --git a/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs new file mode 100644 index 0000000..0a40adc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/ContractTemplateRow.cs @@ -0,0 +1,13 @@ +using NeonSprawl.Server.Game.Quests; + +namespace NeonSprawl.Server.Game.Contracts; + +/// Load-time contract template row (NEO-145). +public sealed record ContractTemplateRow( + string Id, + string DisplayName, + int ZoneDifficultyBand, + string ObjectiveKind, + string EncounterTemplateId, + FactionGateRuleRow MinFactionStanding, + QuestRewardBundleRow CompletionRewardBundle); diff --git a/server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs b/server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs new file mode 100644 index 0000000..a4fe10d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/IContractTemplateRegistry.cs @@ -0,0 +1,15 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Contracts; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +public interface IContractTemplateRegistry +{ + /// Attempts to resolve a contract template by stable id. Unknown ids and null return false without throwing. + bool TryGetDefinition(string? templateId, [NotNullWhen(true)] out ContractTemplateRow? definition); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs b/server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs new file mode 100644 index 0000000..a0cb3f7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Contracts/PrototypeE7M4ContractCatalogRules.cs @@ -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; + +/// +/// Prototype E7M4 contract template catalog gates (NEO-145), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M4_* and _prototype_e7m4_* gate functions. +/// +public static class PrototypeE7M4ContractCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M4_TEMPLATE_IDS. + public static readonly FrozenSet ExpectedTemplateIds = FrozenSet.ToFrozenSet( + ["prototype_contract_clear_combat_pocket"], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M4_BAND_CAPS; NEO-147 ports to issue-time validation. + public static readonly FrozenDictionary BandCapsByDifficultyBand = + new Dictionary + { + [1] = new BandCapPolicy(MaxItemQuantity: 10, MaxSkillXpAmount: 25, MaxReputationAmount: 10), + }.ToFrozenDictionary(); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M4_TEMPLATE_FREEZE. + public static readonly FrozenDictionary ExpectedTemplateFreeze = + new Dictionary(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); + + /// Returns a human-readable error if the prototype template roster fails, otherwise . + public static string? TryGetRosterGateError(IReadOnlyDictionary 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; + } + + /// Returns a human-readable error if template rows diverge from the E7M4 freeze table, otherwise . + public static string? TryGetFreezeGateError(IReadOnlyDictionary 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; + } + + /// Returns a human-readable error when cross-refs fail, otherwise . + public static string? TryGetCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlySet knownEncounterIds, + IReadOnlySet knownFactionIds, + IReadOnlyDictionary 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; + } + + /// Returns a human-readable error if a template bundle exceeds band caps, otherwise . + public static string? TryGetBandCapGateError(IReadOnlyDictionary 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); +} + +/// Prototype economy caps keyed by zone difficulty band (NEO-145). +public sealed record BandCapPolicy(int MaxItemQuantity, int MaxSkillXpAmount, int MaxReputationAmount); diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 50f87ad..a73508e 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -166,4 +166,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of factions directory}/schemas/faction-def.schema.json. /// public string? FactionDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/contracts directory. + /// + public string? ContractsDirectory { get; set; } + + /// + /// Optional override for contract-template.schema.json. + /// When unset, resolved as {parent of contracts directory}/schemas/contract-template.schema.json. + /// + public string? ContractTemplateSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 6aa6483..0f6a688 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,5 +1,6 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; +using NeonSprawl.Server.Game.Contracts; using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Factions; @@ -32,6 +33,7 @@ builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddQuestDefinitionCatalog(builder.Configuration); +builder.Services.AddContractTemplateCatalog(builder.Configuration); builder.Services.AddPlayerQuestStateStore(builder.Configuration); builder.Services.AddRewardDeliveryStore(); builder.Services.AddFactionStandingStores(builder.Configuration); @@ -51,6 +53,7 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/README.md b/server/README.md index a1d05f0..f608cd1 100644 --- a/server/README.md +++ b/server/README.md @@ -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. +## 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) **`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/`. From 937412eda6eccfc9819fb2ff323c28650bab7e37 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 20 Jun 2026 19:31:54 -0400 Subject: [PATCH 3/5] NEO-145: reconcile implementation plan with shipped catalog load. --- docs/plans/NEO-145-implementation-plan.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/plans/NEO-145-implementation-plan.md b/docs/plans/NEO-145-implementation-plan.md index 140b34b..11747a8 100644 --- a/docs/plans/NEO-145-implementation-plan.md +++ b/docs/plans/NEO-145-implementation-plan.md @@ -48,9 +48,16 @@ Fail-fast startup load of `content/contracts/*_contract_templates.json` with **C ## Acceptance criteria checklist -- [ ] Host exits on invalid contract catalog or broken encounter refs at startup. -- [ ] Registry resolves template metadata by id. -- [ ] `dotnet test` covers loader gates. +- [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. ## Technical approach From 1b904b594d48c50883390d05801d2b1de4b63a09 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 20 Jun 2026 19:34:37 -0400 Subject: [PATCH 4/5] NEO-145: add code review for contract catalog load branch. --- docs/reviews/2026-06-20-NEO-145.md | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/reviews/2026-06-20-NEO-145.md diff --git a/docs/reviews/2026-06-20-NEO-145.md b/docs/reviews/2026-06-20-NEO-145.md new file mode 100644 index 0000000..097787f --- /dev/null +++ b/docs/reviews/2026-06-20-NEO-145.md @@ -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. | +| `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. | +| `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. | +| `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. | +| `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. + +## 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. + +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. + +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. + +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. + +## Nits + +- Nit: `Load_ShouldThrow_WhenBundleItemIdUnknown` calls `TryGetCrossRefError` directly rather than `Load`; rename to `TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown` (or route through the loader) for clarity. + +- 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. + +- 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. From 56bc22c70d62769655cd732714d4b94ababd4928 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 20 Jun 2026 19:36:25 -0400 Subject: [PATCH 5/5] =?UTF-8?q?NEO-145:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20tests=20and=20decomposition=20docs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename cross-ref unit test; add loader freeze-overflow and missing-schema negatives; update E7.M4/CT.M1 alignment tables and strike through review. --- .../CT_M1_ContentValidationPipeline.md | 2 +- .../modules/E7_M4_ContractMissionGenerator.md | 2 ++ ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E7M4-pre-production-backlog.md | 4 +-- docs/plans/NEO-145-implementation-plan.md | 3 +- docs/reviews/2026-06-20-NEO-145.md | 22 ++++++------- .../ContractTemplateCatalogLoaderTests.cs | 31 ++++++++++++++++++- 8 files changed, 50 insertions(+), 18 deletions(-) diff --git a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md index d1f0ee5..9e6ac23 100644 --- a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md +++ b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md @@ -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`**. -**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. diff --git a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md index 56efe22..aafbd40 100644 --- a/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md +++ b/docs/decomposition/modules/E7_M4_ContractMissionGenerator.md @@ -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). +**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 - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 7. diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 302b7db..7da445a 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -62,7 +62,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E7.M2 | Ready | **E7M2-01 catalog landed ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** `quest-reward-bundle` + `quest-skill-xp-grant` schemas; **`completionRewardBundle`** on four frozen E7.M1 quests; CI bundle freeze + cross-ref gates. **E7M2-02 server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** fail-fast quest catalog **`completionRewardBundle`** validation at startup — `PrototypeE7M2QuestCatalogRules` + bundle schema `$ref` registration ([NEO-125](../../plans/NEO-125-implementation-plan.md)); [server README — Quest catalog (NEO-125)](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M2-03 delivery store ([NEO-126](https://linear.app/neon-sprawl/issue/NEO-126)):** idempotent **`IRewardDeliveryStore`** + **`RewardDeliveryEvent`** in `Game/Rewards/` — in-memory prototype ([NEO-126](../../plans/NEO-126-implementation-plan.md)); [server README — Reward delivery store (NEO-126)](../../../server/README.md#reward-delivery-store-neo-126). **E7M2-04 router apply ([NEO-127](https://linear.app/neon-sprawl/issue/NEO-127)):** **`RewardRouterOperations.TryDeliverQuestCompletion`** — item + skill XP bundle apply with compensating rollback + store record ([NEO-127](../../plans/NEO-127-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M2-05 quest-state wiring ([NEO-128](https://linear.app/neon-sprawl/issue/NEO-128)):** **`QuestStateOperations.TryMarkComplete`** + **`QuestObjectiveWiring`** deliver-then-mark via router ([NEO-128](../../plans/NEO-128-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M2-06 HTTP read ([NEO-129](https://linear.app/neon-sprawl/issue/NEO-129)):** **`GET …/quest-progress`** **`completionRewardSummary`** from **`IRewardDeliveryStore`** ([NEO-129](../../plans/NEO-129-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/quest-progress/Get quest progress after gather intro complete.bru`. **E7M2-07 telemetry ([NEO-130](https://linear.app/neon-sprawl/issue/NEO-130)):** comment-only **`reward_delivery`** + **`unlock_granted`** stub hook sites in **`RewardRouterOperations.TryDeliverQuestCompletion`** ([NEO-130](../../plans/NEO-130-implementation-plan.md)); [server README — Reward telemetry hooks (NEO-130)](../../../server/README.md#reward-telemetry-hooks-neo-130). **E7M2-08 client HUD ([NEO-131](https://linear.app/neon-sprawl/issue/NEO-131)):** Godot **`QuestRewardDeliveryLabel`** paints **`completionRewardSummary`** on in-session completion transition ([NEO-131](../../plans/NEO-131-implementation-plan.md)); [client README — Quest completion reward HUD (NEO-131)](../../../client/README.md#quest-completion-reward-hud-neo-131). **E7M2-09 client capstone ([NEO-132](https://linear.app/neon-sprawl/issue/NEO-132)):** playable quest reward delivery loop — [`NEO-132` manual QA](../../manual-qa/NEO-132.md); [client README — End-to-end quest reward loop (NEO-132)](../../../client/README.md#end-to-end-quest-reward-loop-neo-132); plan [NEO-132](../../plans/NEO-132-implementation-plan.md). **Epic 7 Slice 2 client capstone complete.** Backlog **E7M2-01** [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132) **landed**. **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-125](../../plans/NEO-125-implementation-plan.md), [NEO-126](../../plans/NEO-126-implementation-plan.md), [NEO-127](../../plans/NEO-127-implementation-plan.md), [NEO-128](../../plans/NEO-128-implementation-plan.md), [NEO-129](../../plans/NEO-129-implementation-plan.md), [NEO-130](../../plans/NEO-130-implementation-plan.md), [NEO-131](../../plans/NEO-131-implementation-plan.md), [NEO-132](../../plans/NEO-132-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | | E7.M1 | Ready | **E7M1-01 catalog landed ([NEO-112](https://linear.app/neon-sprawl/issue/NEO-112)):** quest schemas, `prototype_quests.json`, CI gates (four frozen quest ids, objective cross-refs, acyclic prerequisites, chain terminal token). **E7M1-02 server load ([NEO-113](https://linear.app/neon-sprawl/issue/NEO-113)):** fail-fast startup load of `content/quests/*_quests.json` — `server/NeonSprawl.Server/Game/Quests/` ([NEO-113](../../plans/NEO-113-implementation-plan.md)); [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). **E7M1-03 registry ([NEO-114](https://linear.app/neon-sprawl/issue/NEO-114)):** **`IQuestDefinitionRegistry`** + DI ([NEO-114](../../plans/NEO-114-implementation-plan.md)). **E7M1-04 HTTP read ([NEO-115](https://linear.app/neon-sprawl/issue/NEO-115)):** **`GET /game/world/quest-definitions`** — `QuestDefinitionsWorldApi` + DTOs ([NEO-115](../../plans/NEO-115-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115); Bruno `bruno/neon-sprawl-server/quest-definitions/`. **E7M1-05 store ([NEO-116](https://linear.app/neon-sprawl/issue/NEO-116)):** **`IPlayerQuestStateStore`** + in-memory/Postgres persistence ([NEO-116](../../plans/NEO-116-implementation-plan.md)); [server README — Quest progress store (NEO-116)](../../../server/README.md#quest-progress-store-neo-116). **E7M1-06 operations ([NEO-117](https://linear.app/neon-sprawl/issue/NEO-117)):** **`QuestStateOperations`** + reason codes ([NEO-117](../../plans/NEO-117-implementation-plan.md)); [server README — Quest state operations (NEO-117)](../../../server/README.md#quest-state-operations-neo-117). **E7M1-07 objective wiring ([NEO-118](https://linear.app/neon-sprawl/issue/NEO-118)):** **`QuestObjectiveWiring`** on gather/craft/encounter + **`inventory_has_item`** snapshot passes ([NEO-118](../../plans/NEO-118-implementation-plan.md)); [server README — Quest objective wiring (NEO-118)](../../../server/README.md#quest-objective-wiring-neo-118). **E7M1-08 per-player GET ([NEO-119](https://linear.app/neon-sprawl/issue/NEO-119)):** **`GET /game/players/{id}/quest-progress`** — `QuestProgressApi` + DTOs; GET-side inventory refresh ([NEO-119](../../plans/NEO-119-implementation-plan.md)); [server README — Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119); Bruno `bruno/neon-sprawl-server/quest-progress/`. **E7M1-09 accept POST ([NEO-120](https://linear.app/neon-sprawl/issue/NEO-120)):** **`POST /game/players/{id}/quests/{questId}/accept`** — `QuestAcceptApi` + DTOs; **`QuestStateOperations.TryAccept`** ([NEO-120](../../plans/NEO-120-implementation-plan.md)); [server README — Quest accept POST (NEO-120)](../../../server/README.md#quest-accept-post-neo-120); Bruno accept spine in `bruno/neon-sprawl-server/quest-progress/`. **E7M1-10 telemetry ([NEO-121](https://linear.app/neon-sprawl/issue/NEO-121)):** comment-only **`quest_start`**, **`quest_step_complete`**, **`quest_complete`**, **`quest_accept_denied`** hook sites in **`QuestStateOperations`** ([NEO-121](../../plans/NEO-121-implementation-plan.md)); [server README — Quest telemetry hooks (NEO-121)](../../../server/README.md#quest-telemetry-hooks-neo-121). **E7M1-11 client HUD ([NEO-122](https://linear.app/neon-sprawl/issue/NEO-122)):** **`quest_progress_client.gd`**, **`quest_definitions_client.gd`**, **`QuestProgressLabel`** / **`QuestAcceptFeedbackLabel`**; boot + event-driven GET refresh; **Q** / **Shift+Q** accept ([NEO-122](../../plans/NEO-122-implementation-plan.md), [`NEO-122` manual QA](../../manual-qa/NEO-122.md)); [client README — Quest progress + accept HUD (NEO-122)](../../../client/README.md#quest-progress--accept-hud-neo-122). **E7M1-12 client capstone ([NEO-123](https://linear.app/neon-sprawl/issue/NEO-123)):** playable four-quest onboarding chain — [`NEO-123` manual QA](../../manual-qa/NEO-123.md); [client README — End-to-end onboarding quest loop (NEO-123)](../../../client/README.md#end-to-end-onboarding-quest-loop-neo-123); plan [NEO-123](../../plans/NEO-123-implementation-plan.md). **Epic 7 Slice 1 client capstone complete.** Backlog **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) **landed**. Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **`contract_handoff_token`** + **`EncounterCompleteEvent`** **Ready**. | [NEO-123 plan](../../plans/NEO-123-implementation-plan.md), [NEO-122 plan](../../plans/NEO-122-implementation-plan.md), [NEO-121 plan](../../plans/NEO-121-implementation-plan.md), [NEO-120 plan](../../plans/NEO-120-implementation-plan.md), [NEO-119 plan](../../plans/NEO-119-implementation-plan.md), [NEO-118 plan](../../plans/NEO-118-implementation-plan.md), [NEO-117 plan](../../plans/NEO-117-implementation-plan.md), [NEO-116 plan](../../plans/NEO-116-implementation-plan.md), [NEO-115 plan](../../plans/NEO-115-implementation-plan.md), [NEO-114 plan](../../plans/NEO-114-implementation-plan.md), [NEO-113 plan](../../plans/NEO-113-implementation-plan.md), [NEO-112 plan](../../plans/NEO-112-implementation-plan.md), [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1](E7_M1_QuestStateMachine.md), label **`E7.M1`** on NEO-112–NEO-123 | | E7.M3 | Ready | **E7M3-01 catalog landed ([NEO-133](https://linear.app/neon-sprawl/issue/NEO-133)):** faction + gate/rep schemas, `prototype_factions.json`, quest extensions, five-quest CI gates, minimal server roster parity. **E7M3-02 server load landed ([NEO-134](https://linear.app/neon-sprawl/issue/NEO-134)):** fail-fast faction catalog, `IFactionDefinitionRegistry`, quest `factionGateRules` / `reputationGrants` parse + E7M3 cross-ref/bundle/grid gates ([NEO-134 plan](../../plans/NEO-134-implementation-plan.md)). **E7M3-03 stores landed ([NEO-135](https://linear.app/neon-sprawl/issue/NEO-135)):** `IFactionStandingStore`, `IReputationDeltaStore`, in-memory + Postgres `V009`/`V010` ([NEO-135 plan](../../plans/NEO-135-implementation-plan.md)); [server README — Faction standing store (NEO-135)](../../../server/README.md#faction-standing-store-neo-135). **E7M3-04 ops landed ([NEO-136](https://linear.app/neon-sprawl/issue/NEO-136)):** `ReputationOperations.TryApplyDelta` — standing + audit orchestration with compensating rollback ([NEO-136 plan](../../plans/NEO-136-implementation-plan.md)); [server README — ReputationOperations (NEO-136)](../../../server/README.md#reputationoperations-neo-136). **E7M3-05 gate eval landed ([NEO-137](https://linear.app/neon-sprawl/issue/NEO-137)):** `FactionGateOperations.TryEvaluate` wired into `QuestStateOperations.TryAccept`; `faction_gate_blocked` deny ([NEO-137 plan](../../plans/NEO-137-implementation-plan.md)); [server README — FactionGateOperations (NEO-137)](../../../server/README.md#factiongateoperations-neo-137). **E7M3-06 reward router landed ([NEO-138](https://linear.app/neon-sprawl/issue/NEO-138)):** `RewardRouterOperations` applies `reputationGrants` via `ReputationOperations`; Bruno grid-contract accept success after organic operator chain ([NEO-138 plan](../../plans/NEO-138-implementation-plan.md)); [server README — Reward router (NEO-127)](../../../server/README.md#reward-router-neo-127). **E7M3-07 HTTP read landed ([NEO-139](https://linear.app/neon-sprawl/issue/NEO-139)):** **`GET …/faction-standing`** — `FactionStandingApi` + DTOs; Bruno `bruno/neon-sprawl-server/faction-standing/` ([NEO-139 plan](../../plans/NEO-139-implementation-plan.md)); [server README — Faction standing read (NEO-139)](../../../server/README.md#faction-standing-read-neo-139). **E7M3-08 quest HTTP projections landed ([NEO-140](https://linear.app/neon-sprawl/issue/NEO-140)):** world GET **`factionGateRules`** + quest-progress **`completionRewardSummary.reputationGrants`**; Bruno quest-definitions + operator-chain progress bru ([NEO-140 plan](../../plans/NEO-140-implementation-plan.md)); [server README — Quest definitions (NEO-115)](../../../server/README.md#quest-definitions-neo-115), [Per-player quest progress (NEO-119)](../../../server/README.md#per-player-quest-progress-neo-119). **E7M3-09 telemetry landed ([NEO-141](https://linear.app/neon-sprawl/issue/NEO-141)):** comment-only **`reputation_delta`** + **`faction_gate_blocked`** hook sites in ops layers ([NEO-141 plan](../../plans/NEO-141-implementation-plan.md)); [server README — Faction telemetry hooks (NEO-141)](../../../server/README.md#faction-telemetry-hooks-neo-141). **E7M3-10 client faction HUD landed ([NEO-142](https://linear.app/neon-sprawl/issue/NEO-142)):** `faction_standing_client.gd`, standing label + gate deny + rep reward lines ([NEO-142 plan](../../plans/NEO-142-implementation-plan.md)); [client README — Faction standing + gate feedback HUD (NEO-142)](../../../client/README.md#faction-standing--gate-feedback-hud-neo-142); [`NEO-142` manual QA](../../manual-qa/NEO-142.md). **E7M3-11 client capstone landed ([NEO-143](https://linear.app/neon-sprawl/issue/NEO-143)):** playable faction reputation + gate capstone — [`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.** Two frozen factions; operator-chain **`reputationGrants`** (+15 Grid Operators); gated **`prototype_quest_grid_contract`**. Upstream: E7.M1 **Ready**, E7.M2 **Ready**. | [NEO-133 plan](../../plans/NEO-133-implementation-plan.md), [NEO-134 plan](../../plans/NEO-134-implementation-plan.md), [NEO-135 plan](../../plans/NEO-135-implementation-plan.md), [NEO-136 plan](../../plans/NEO-136-implementation-plan.md), [NEO-137 plan](../../plans/NEO-137-implementation-plan.md), [NEO-138 plan](../../plans/NEO-138-implementation-plan.md), [NEO-139 plan](../../plans/NEO-139-implementation-plan.md), [NEO-140 plan](../../plans/NEO-140-implementation-plan.md), [NEO-141 plan](../../plans/NEO-141-implementation-plan.md), [NEO-142 plan](../../plans/NEO-142-implementation-plan.md), [NEO-143 plan](../../plans/NEO-143-implementation-plan.md), [E7M3-pre-production-backlog.md](../../plans/E7M3-pre-production-backlog.md), [E7_M3](E7_M3_FactionReputationLedger.md), label **`E7.M3`** on NEO-133–NEO-143 | -| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | +| E7.M4 | In Progress | **E7M4-01 catalog landed ([NEO-144](https://linear.app/neon-sprawl/issue/NEO-144)):** `contract-template` + `contract-seed` schemas; `prototype_contract_templates.json`; CI roster/freeze/cross-ref/band-cap gates. **E7M4-02 server load landed ([NEO-145](https://linear.app/neon-sprawl/issue/NEO-145)):** fail-fast contract template catalog, `IContractTemplateRegistry`, `PrototypeE7M4ContractCatalogRules` at startup ([NEO-145 plan](../../plans/NEO-145-implementation-plan.md)); [server README — Contract template catalog](../../../server/README.md#contract-template-catalog-contentcontracts-neo-145). **Decomposition landed:** Epic 7 Slice 4 backlog [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md) — **E7M4-01** [NEO-144](https://linear.app/neon-sprawl/issue/NEO-144) → **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154); label **`E7.M4`**. Prototype spine: **`prototype_contract_clear_combat_pocket`** template → **`prototype_combat_pocket`** encounter clear → repeat payout **`scrap_metal_bulk` ×5** + salvage **15**; one active contract per player; economy band caps at issue; E4.M1 live zone deferred (content **`zoneDifficultyBand`**). Client capstone **E7M4-11** [NEO-154](https://linear.app/neon-sprawl/issue/NEO-154). Upstream: E5.M3 **Ready**, E7.M2 router **Ready**, E7.M3 standing **Ready**. | [E7M4-pre-production-backlog.md](../../plans/E7M4-pre-production-backlog.md), [E7_M4](E7_M4_ContractMissionGenerator.md), [NEO-144](../../plans/NEO-144-implementation-plan.md), [NEO-145](../../plans/NEO-145-implementation-plan.md), label **`E7.M4`** on NEO-144–NEO-154 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index c287c36..8aeee37 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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.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 diff --git a/docs/plans/E7M4-pre-production-backlog.md b/docs/plans/E7M4-pre-production-backlog.md index 23d9fa0..f9fa208 100644 --- a/docs/plans/E7M4-pre-production-backlog.md +++ b/docs/plans/E7M4-pre-production-backlog.md @@ -132,8 +132,8 @@ Working backlog for **Epic 7 — Slice 4** ([contract generator](../decompositio **Acceptance criteria** -- [ ] Host exits on invalid contract catalog or broken encounter refs at startup. -- [ ] Registry resolves template metadata by id. +- [x] Host exits on invalid contract catalog or broken encounter refs at startup. +- [x] Registry resolves template metadata by id. **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-145-implementation-plan.md b/docs/plans/NEO-145-implementation-plan.md index 11747a8..1c194df 100644 --- a/docs/plans/NEO-145-implementation-plan.md +++ b/docs/plans/NEO-145-implementation-plan.md @@ -57,7 +57,8 @@ Fail-fast startup load of `content/contracts/*_contract_templates.json` with **C - **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. +- **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 diff --git a/docs/reviews/2026-06-20-NEO-145.md b/docs/reviews/2026-06-20-NEO-145.md index 097787f..cb3151e 100644 --- a/docs/reviews/2026-06-20-NEO-145.md +++ b/docs/reviews/2026-06-20-NEO-145.md @@ -18,14 +18,14 @@ NEO-145 delivers fail-fast startup loading of `content/contracts/*_contract_temp |----------|-----------| | `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. | -| `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. | -| `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. | -| `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. | +| `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. +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 @@ -33,19 +33,19 @@ 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. +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. +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. +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. +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. +- ~~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. +- ~~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. diff --git a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs index 8222ad6..7153d3b 100644 --- a/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Contracts/ContractTemplateCatalogLoaderTests.cs @@ -194,7 +194,7 @@ public class ContractTemplateCatalogLoaderTests } [Fact] - public void Load_ShouldThrow_WhenBundleItemIdUnknown() + public void TryGetCrossRefError_ShouldReturnError_WhenBundleItemIdUnknown() { // Arrange var prototype = PrototypeE7M4ContractCatalogRules.ExpectedTemplateFreeze["prototype_contract_clear_combat_pocket"]; @@ -239,6 +239,35 @@ public class ContractTemplateCatalogLoaderTests 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(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(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + Assert.Contains(schemaPath, ioe.Message, StringComparison.Ordinal); + } + [Fact] public void TryGetBandCapGateError_ShouldReturnError_WhenItemQuantityExceedsBandCap() {