From 47ddde671f607dd2f2aa742c4be06f4648fcf94d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 15:23:21 -0400 Subject: [PATCH 1/5] NEO-125: add implementation plan for quest bundle catalog load --- docs/plans/NEO-125-implementation-plan.md | 156 ++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/plans/NEO-125-implementation-plan.md diff --git a/docs/plans/NEO-125-implementation-plan.md b/docs/plans/NEO-125-implementation-plan.md new file mode 100644 index 0000000..814154c --- /dev/null +++ b/docs/plans/NEO-125-implementation-plan.md @@ -0,0 +1,156 @@ +# NEO-125 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-125 | +| **Title** | E7M2-02: Server quest catalog load — reward bundle validation | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-125/e7m2-02-server-quest-catalog-load-reward-bundle-validation | +| **Module** | [E7.M2 — RewardAndUnlockRouter](../decomposition/modules/E7_M2_RewardAndUnlockRouter.md) · Epic 7 Slice 2 · backlog **E7M2-02** | +| **Branch** | `NEO-125-e7m2-server-quest-catalog-reward-bundle-validation` | +| **Precursor** | [NEO-124](https://linear.app/neon-sprawl/issue/NEO-124) **`Done`** — `completionRewardBundle` schemas, catalog rows, CI gates (**landed on `main`**) | +| **Pattern** | [NEO-113](NEO-113-implementation-plan.md) — in-process C# gates mirroring `scripts/validate_content.py`; [NEO-101](NEO-101-implementation-plan.md) — multi-schema `$ref` registration (reward-table loader) | +| **Blocks** | [NEO-127](https://linear.app/neon-sprawl/issue/NEO-127) (router apply) via [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) parallel path; catalog must load before delivery stories | +| **Client counterpart** | none — infrastructure-only ([E7M2-prototype-backlog](E7M2-prototype-backlog.md#e7m2-02--server-quest-catalog-load-reward-bundle-validation)) | + +## Kickoff clarifications + +**No clarifications needed.** Linear goal (“CI parity”), E7M2 backlog in/out-of-scope, and [NEO-113](NEO-113-implementation-plan.md) / [NEO-124](NEO-124-implementation-plan.md) precedents settle: + +- **Full E7M2 gate parity** on the server (presence + freeze table + item/skill cross-refs + **`mission_reward`** allowlist) — not cross-ref-only. +- **Parse typed bundle rows** onto `QuestDefRow` now — backlog “extend catalog loader / registry types”; [NEO-127](NEO-127) router reads catalog without re-parsing JSON. +- **Reuse `RewardGrantRow`** (Encounters) for **`itemGrants`** — same schema shape as E5.M3 / CI; add **`QuestSkillXpGrantRow`** + **`QuestRewardBundleRow`** under `Game/Quests/`. +- **Separate `PrototypeE7M2QuestCatalogRules`** — mirrors E7M1 rules class + Python `PROTOTYPE_E7M2_*` constants. +- **Host boot tests** — same kickoff default as NEO-113 (loader + `WebApplicationFactory`). + +**Known gap on `main` after NEO-124:** quest catalog load throws `RefResolutionException` for `quest-reward-bundle.json` because bundle schemas are not registered in `CatalogSchemaRegistry` — fixing this is in scope. + +## Goal, scope, and out-of-scope + +**Goal:** Fail-fast startup validation of **`completionRewardBundle`** with CI parity; host refuses to listen when bundle refs or prototype freeze rules fail. + +**In scope (from Linear + [E7M2-02](E7M2-prototype-backlog.md#e7m2-02--server-quest-catalog-load-reward-bundle-validation)):** + +- Extend `server/NeonSprawl.Server/Game/Quests/` loader, DTOs, and prototype rules. +- Register bundle-related JSON Schemas before row validation. +- Cross-check **`itemGrants[].itemId`** against the item catalog and **`skillXpGrants[].skillId`** against the skill catalog + **`allowedXpSourceKinds`** (must include **`mission_reward`**). +- Unit tests (AAA) for happy path and broken bundle refs; host boot still resolves catalog. +- `server/README.md` quest-catalog section update. + +**Out of scope (from Linear):** + +- Delivery apply, **`IRewardDeliveryStore`**, router, HTTP projection changes (NEO-126+). +- Godot client. + +## Acceptance criteria checklist + +- [ ] Host exits on invalid bundle refs at startup. +- [ ] `dotnet test` covers loader gates. + +## Technical approach + +### 1. Bundle DTOs (`Game/Quests/`) + +- **`QuestSkillXpGrantRow`** — `SkillId`, `Amount` (record or sealed class, same style as `QuestObjectiveDefRow`). +- **`QuestRewardBundleRow`** — `ItemGrants` (`IReadOnlyList` from `Game.Encounters`), `SkillXpGrants` (`IReadOnlyList`). +- Extend **`QuestDefRow`** with optional **`CompletionRewardBundle`** (`QuestRewardBundleRow?`). + +### 2. `PrototypeE7M2QuestCatalogRules` + +New static class synced to `scripts/validate_content.py`: + +| Constant / helper | Python source | +|-------------------|---------------| +| `MissionRewardSourceKind` = `"mission_reward"` | `PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND` | +| `ExpectedCompletionBundles` | `PROTOTYPE_E7M2_COMPLETION_BUNDLES` (normalized item + skill grant rows per quest id) | +| `TryGetCompletionBundlePresenceError(rowsById)` | `_prototype_e7m2_completion_bundle_presence_gate` | +| `TryGetCompletionBundleFreezeError(rowsById)` | `_prototype_e7m2_completion_bundle_freeze_gate` (sort grant rows before compare) | +| `TryGetCompletionBundleCrossRefError(rowsById, knownItemIds, skillDefsById)` | `_prototype_e7m2_completion_bundle_cross_ref_gate` | + +Presence/freeze gates apply only to ids in **`PrototypeE7M1QuestCatalogRules.ExpectedQuestIds`** (four frozen quests). + +### 3. Schema registration (`QuestDefinitionCatalogLoader`) + +Before evaluating `quest-def.schema.json`, register (dependency order, same as CI `$ref` registry): + +1. `reward-grant-row.schema.json` +2. `quest-skill-xp-grant.schema.json` +3. `quest-reward-bundle.schema.json` +4. Existing objective → step → def chain + +Add path resolvers on **`QuestCatalogPathResolution`** (sibling `content/schemas/` defaults, optional `ContentPathsOptions` overrides if needed for symmetry — can derive from quests directory like step schema). + +Extend **`EnsureQuestSchemasLoaded`** signature to accept bundle schema paths; validate schema files exist at load start. + +### 4. Loader parse + gates + +- **`ParseRow`**: when `completionRewardBundle` present, parse into **`QuestRewardBundleRow`** (omit empty **`itemGrants`** arrays). +- After existing E7M1 gates, run E7M2 rules in order: presence → freeze → cross-ref. +- Extend **`Load(...)`** signature with **`IReadOnlyDictionary skillDefsById`** (or `SkillDefinitionCatalog` + extract `.ById` in DI factory). + +### 5. DI (`QuestCatalogServiceCollectionExtensions`) + +- Inject **`SkillDefinitionCatalog`** alongside item / recipe / encounter catalogs. +- Pass **`skillCatalog.ById`** into loader (skills already register before quests in **`Program.cs`**). + +### 6. Tests (`QuestDefinitionCatalogLoaderTests`) + +- Update **`ValidPrototypeCatalogJson`** with **`completionRewardBundle`** on all four quests (match repo / freeze table). +- **`CreateTempContentLayout`**: copy bundle + grant-row schemas from repo (add helpers on **`QuestCatalogTestPaths`**). +- Extend **`LoadCatalog`** helper with frozen skill defs map (reuse repo skill catalog discovery or inline salvage/refine rows with **`mission_reward`** in **`AllowedXpSourceKinds`**). +- New negative tests (AAA): + - Missing **`completionRewardBundle`** on a frozen quest id. + - Wrong freeze-table XP amount. + - Unknown **`itemId`** in bundle. + - Unknown **`skillId`** in bundle. + - Skill missing **`mission_reward`** in **`allowedXpSourceKinds`**. +- Existing **`Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile`** and **`Host_ShouldResolveQuestCatalogFromDi`** must pass again. + +### 7. Docs + +- **`server/README.md`** — extend quest catalog paragraph: bundle schema `$ref`s, E7M2 presence/freeze/cross-ref gates, skill catalog dependency. +- **`E7M2-prototype-backlog.md`** — check E7M2-02 AC boxes when shipped. +- **`documentation_and_implementation_alignment.md`** — E7.M2 row note server load (NEO-125). + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs` | Skill XP row on a completion bundle. | +| `server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs` | Composite bundle DTO (`ItemGrants`, `SkillXpGrants`). | +| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs` | E7M2 prototype gates + constants synced to `validate_content.py`. | +| `docs/plans/NEO-125-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Add optional **`CompletionRewardBundle`** property. | +| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Register bundle schemas; parse bundle; run E7M2 gates; accept skill defs. | +| `server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs` | Resolve paths for bundle / skill-xp-grant / reward-grant-row schemas. | +| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | Pass **`SkillDefinitionCatalog`** into loader. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Repo discovery helpers for new schema files. | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | Bundle fixtures, skill map, new failure tests; fix regressions from NEO-124 content. | +| `server/README.md` | Document E7M2 bundle validation at quest catalog load. | +| `docs/plans/E7M2-prototype-backlog.md` | Mark E7M2-02 AC when complete. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M2 alignment — server bundle load landed. | + +## Tests + +| Target | Coverage | +|--------|----------| +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | Happy path (repo + temp fixture with bundles); E7M2 presence, freeze, unknown item/skill, missing **`mission_reward`** allowlist; existing E7M1 tests unchanged; host DI resolve. | +| **No new test files** | Extend existing loader test class per NEO-113 precedent. | +| **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter QuestDefinitionCatalogLoaderTests`; boot dev server and confirm quest catalog Information log after NEO-124 content. | + +## Open questions / risks + +| Question or risk | Agent recommendation | Status | +|------------------|----------------------|--------| +| Constants drift vs `validate_content.py` | Sync comments on `PrototypeE7M2QuestCatalogRules` → `PROTOTYPE_E7M2_*`; same pattern as E7M1 rules. | **adopted** | +| `QuestDefRow` bundle null on future non-prototype quests | Optional property; E7M2 gates only enforce bundles on four frozen ids. | **adopted** | +| Cross-namespace `RewardGrantRow` reuse | Reuse Encounters struct — identical JSON shape; avoids duplicate DTO. | **adopted** | +| HTTP quest-definitions omits bundle | Out of scope; NEO-115 DTOs unchanged until a client story needs bundle preview. | **deferred** | + +None beyond the above. From 39246956192b8486866077786af528ce4c276bdb Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 15:25:58 -0400 Subject: [PATCH 2/5] NEO-125: fail-fast quest completionRewardBundle validation at startup Register bundle JSON Schemas, parse bundle DTOs on QuestDefRow, and run E7M2 prototype gates (presence, freeze table, cross-ref, mission_reward) with skill catalog input from DI. --- .../Game/Quests/QuestCatalogTestPaths.cs | 9 + .../QuestDefinitionCatalogLoaderTests.cs | 144 +++++++++++++- .../Quests/PrototypeE7M2QuestCatalogRules.cs | 183 ++++++++++++++++++ .../Game/Quests/QuestCatalogPathResolution.cs | 12 ++ ...QuestCatalogServiceCollectionExtensions.cs | 8 + .../Game/Quests/QuestDefRow.cs | 5 +- .../Quests/QuestDefinitionCatalogLoader.cs | 108 ++++++++++- .../Game/Quests/QuestRewardBundleRow.cs | 8 + .../Game/Quests/QuestSkillXpGrantRow.cs | 4 + server/README.md | 4 +- 10 files changed, 477 insertions(+), 8 deletions(-) create mode 100644 server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs create mode 100644 server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs index 043ab31..78f953f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs @@ -26,4 +26,13 @@ internal static class QuestCatalogTestPaths DiscoverRepoQuestsDirectory(), configuredSchemaPath: null, contentRootPath: string.Empty); + + internal static string DiscoverRepoRewardGrantRowSchemaPath() => + QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(DiscoverRepoQuestsDirectory()); + + internal static string DiscoverRepoQuestSkillXpGrantSchemaPath() => + QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(DiscoverRepoQuestsDirectory()); + + internal static string DiscoverRepoQuestRewardBundleSchemaPath() => + QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(DiscoverRepoQuestsDirectory()); } diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index 03595ae..d74eafc 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -9,7 +9,9 @@ using NeonSprawl.Server.Game.Crafting; using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Tests; +using NeonSprawl.Server.Tests.Game.Skills; using Xunit; namespace NeonSprawl.Server.Tests.Game.Quests; @@ -22,6 +24,17 @@ public class QuestDefinitionCatalogLoaderTests private static readonly FrozenSet KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds; + private static readonly IReadOnlyDictionary RepoSkillDefs = LoadRepoSkillDefs(); + + private static IReadOnlyDictionary LoadRepoSkillDefs() + { + var catalog = SkillDefinitionCatalogLoader.Load( + SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(), + SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), + NullLogger.Instance); + return catalog.ById; + } + private const string ValidPrototypeCatalogJson = """ { @@ -31,6 +44,9 @@ public class QuestDefinitionCatalogLoaderTests "id": "prototype_quest_gather_intro", "displayName": "Intro: Salvage Run", "prerequisiteQuestIds": [], + "completionRewardBundle": { + "skillXpGrants": [{ "skillId": "salvage", "amount": 25 }] + }, "steps": [ { "id": "gather_intro_step_salvage", @@ -50,6 +66,9 @@ public class QuestDefinitionCatalogLoaderTests "id": "prototype_quest_refine_intro", "displayName": "Intro: Refine Stock", "prerequisiteQuestIds": ["prototype_quest_gather_intro"], + "completionRewardBundle": { + "skillXpGrants": [{ "skillId": "refine", "amount": 25 }] + }, "steps": [ { "id": "refine_intro_step_craft", @@ -69,6 +88,9 @@ public class QuestDefinitionCatalogLoaderTests "id": "prototype_quest_combat_intro", "displayName": "Intro: Clear the Pocket", "prerequisiteQuestIds": [], + "completionRewardBundle": { + "skillXpGrants": [{ "skillId": "salvage", "amount": 25 }] + }, "steps": [ { "id": "combat_intro_step_encounter", @@ -91,6 +113,10 @@ public class QuestDefinitionCatalogLoaderTests "prototype_quest_refine_intro", "prototype_quest_combat_intro" ], + "completionRewardBundle": { + "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], + "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] + }, "steps": [ { "id": "chain_step_gather", @@ -159,6 +185,9 @@ public class QuestDefinitionCatalogLoaderTests File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true); File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), Path.Combine(schemaDir, "reward-grant-row.schema.json"), overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestSkillXpGrantSchemaPath(), Path.Combine(schemaDir, "quest-skill-xp-grant.schema.json"), overwrite: true); + File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestRewardBundleSchemaPath(), Path.Combine(schemaDir, "quest-reward-bundle.schema.json"), overwrite: true); return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath); } @@ -182,15 +211,20 @@ public class QuestDefinitionCatalogLoaderTests string questsDir, string defSchemaPath, string stepSchemaPath, - string objectiveSchemaPath) => + string objectiveSchemaPath, + IReadOnlyDictionary? skillDefsById = null) => QuestDefinitionCatalogLoader.Load( questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, + QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir), + QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir), + QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir), KnownItemIds, KnownRecipeIds, KnownEncounterIds, + skillDefsById ?? RepoSkillDefs, NullLogger.Instance); [Fact] @@ -229,6 +263,10 @@ public class QuestDefinitionCatalogLoaderTests Assert.Equal(4, chain!.Steps.Count); Assert.Equal("inventory_has_item", chain.Steps[^1].Objectives[0].Kind); Assert.Equal("contract_handoff_token", chain.Steps[^1].Objectives[0].ItemId); + Assert.NotNull(gather!.CompletionRewardBundle); + Assert.Single(gather.CompletionRewardBundle!.SkillXpGrants); + Assert.Equal("salvage", gather.CompletionRewardBundle.SkillXpGrants[0].SkillId); + Assert.Equal(25, gather.CompletionRewardBundle.SkillXpGrants[0].Amount); } [Fact] @@ -567,6 +605,110 @@ public class QuestDefinitionCatalogLoaderTests Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal); } + [Fact] + public void Load_ShouldThrow_WhenMissingCompletionRewardBundleOnFrozenQuest() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + gather.Remove("completionRewardBundle"); + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("must include completionRewardBundle object", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenCompletionRewardBundleAmountDoesNotMatchFreezeTable() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + var bundle = gather["completionRewardBundle"] as JsonObject + ?? throw new InvalidOperationException("expected bundle"); + var skillXpGrants = bundle["skillXpGrants"] as JsonArray + ?? throw new InvalidOperationException("expected skillXpGrants"); + var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant"); + grant["amount"] = 99; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var chain = GetQuestRow(root, "prototype_quest_operator_chain"); + var bundle = chain["completionRewardBundle"] as JsonObject + ?? throw new InvalidOperationException("expected bundle"); + var itemGrants = bundle["itemGrants"] as JsonArray + ?? throw new InvalidOperationException("expected itemGrants"); + var grant = itemGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant"); + grant["itemId"] = "unknown_item_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert — freeze gate runs before cross-ref (same order as validate_content.py) + var ioe = Assert.IsType(ex); + Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + var gather = GetQuestRow(root, "prototype_quest_gather_intro"); + var bundle = gather["completionRewardBundle"] as JsonObject + ?? throw new InvalidOperationException("expected bundle"); + var skillXpGrants = bundle["skillXpGrants"] as JsonArray + ?? throw new InvalidOperationException("expected skillXpGrants"); + var grant = skillXpGrants[0] as JsonObject ?? throw new InvalidOperationException("expected grant"); + grant["skillId"] = "unknown_skill_id"; + WriteCatalog(questsDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath)); + // Assert — freeze gate runs before cross-ref (same order as validate_content.py) + var ioe = Assert.IsType(ex); + Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSkillMissingMissionRewardAllowlist() + { + // Arrange + var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object"); + WriteCatalog(questsDir, root.ToJsonString()); + var salvageNoMissionReward = new SkillDefRow("salvage", "gather", "Salvage", ["activity"]); + var skillDefs = new Dictionary(RepoSkillDefs, StringComparer.Ordinal) + { + ["salvage"] = salvageNoMissionReward, + }; + // Act + var ex = Record.Exception(() => + LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, skillDefs)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenMissingSchemaFile() { diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs new file mode 100644 index 0000000..ad74153 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs @@ -0,0 +1,183 @@ +using System.Collections.Frozen; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Quests; + +/// +/// Prototype E7M2 completion bundle gates (NEO-125), mirrored from +/// scripts/validate_content.py PROTOTYPE_E7M2_* and _prototype_e7m2_* gate functions. +/// +public static class PrototypeE7M2QuestCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND. + public const string MissionRewardSourceKind = "mission_reward"; + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E7M2_COMPLETION_BUNDLES. + public static readonly FrozenDictionary ExpectedCompletionBundles = + new Dictionary(StringComparer.Ordinal) + { + [PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("salvage", 25)]), + [PrototypeE7M1QuestCatalogRules.RefineIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("refine", 25)]), + [PrototypeE7M1QuestCatalogRules.CombatIntroQuestId] = new( + [], + [new QuestSkillXpGrantRow("salvage", 25)]), + [PrototypeE7M1QuestCatalogRules.ChainQuestId] = new( + [new RewardGrantRow("survey_drone_kit", 1)], + [new QuestSkillXpGrantRow("salvage", 50)]), + }.ToFrozenDictionary(StringComparer.Ordinal); + + /// Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise . + public static string? TryGetCompletionBundlePresenceError(IReadOnlyDictionary rowsById) + { + foreach (var qid in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal)) + { + if (!rowsById.TryGetValue(qid, out var row)) + return $"error: missing quest '{qid}'"; + + if (row.CompletionRewardBundle is null) + return $"error: quest '{qid}' must include completionRewardBundle object"; + } + + return null; + } + + /// Returns a human-readable error if bundle contents diverge from the E7M2 freeze table, otherwise . + public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary rowsById) + { + foreach (var (qid, expected) in ExpectedCompletionBundles) + { + if (!rowsById.TryGetValue(qid, out var row)) + return $"error: missing quest '{qid}'"; + + if (row.CompletionRewardBundle is null) + return $"error: quest '{qid}' must include completionRewardBundle object"; + + var actual = NormalizeBundle(row.CompletionRewardBundle); + var expectedNormalized = NormalizeBundle(expected); + if (!BundlesEqual(actual, expectedNormalized)) + { + return + $"error: quest '{qid}' completionRewardBundle must match E7M2 freeze table " + + $"(expected {FormatBundle(expectedNormalized)}, got {FormatBundle(actual)})"; + } + } + + return null; + } + + /// Returns a human-readable error when bundle cross-refs fail, otherwise . + public static string? TryGetCompletionBundleCrossRefError( + IReadOnlyDictionary rowsById, + IReadOnlyDictionary skillDefsById) + { + foreach (var qid in PrototypeE7M1QuestCatalogRules.ExpectedQuestIds.Order(StringComparer.Ordinal)) + { + if (!rowsById.TryGetValue(qid, out var row) || row.CompletionRewardBundle is null) + continue; + + 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: quest '{qid}' 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: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + + $"'{grant.SkillId}' is not in the frozen prototype skill catalog"; + } + + if (!skillDefsById.TryGetValue(grant.SkillId, out var skill)) + { + return + $"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + + $"'{grant.SkillId}' is not in the frozen prototype skill catalog"; + } + + var allowedKinds = skill.AllowedXpSourceKinds; + if (!allowedKinds.Contains(MissionRewardSourceKind, StringComparer.Ordinal)) + { + var kindsDisplay = $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]"; + return + $"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + + $"'{grant.SkillId}' must allow sourceKind '{MissionRewardSourceKind}' in allowedXpSourceKinds " + + $"(got {kindsDisplay})"; + } + } + } + + 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()); + + private static bool BundlesEqual(NormalizedBundle left, NormalizedBundle right) + { + if (left.ItemGrants.Length != right.ItemGrants.Length || + left.SkillXpGrants.Length != right.SkillXpGrants.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; + } + } + + 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} }}")); + return + $"{{ itemGrants: [{itemGrants}], skillXpGrants: [{skillXpGrants}] }}"; + } + + private sealed record NormalizedBundle( + RewardGrantRow[] ItemGrants, + QuestSkillXpGrantRow[] SkillXpGrants); +} diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs index 96cec83..6b75aa0 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs @@ -93,4 +93,16 @@ public static class QuestCatalogPathResolution return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json")); } + + /// Resolves JSON Schema path for a reward grant row (shared by reward tables and quest bundles). + public static string ResolveRewardGrantRowSchemaPath(string questsDirectory) => + Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "reward-grant-row.schema.json")); + + /// Resolves JSON Schema path for a quest skill XP grant row. + public static string ResolveQuestSkillXpGrantSchemaPath(string questsDirectory) => + Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json")); + + /// Resolves JSON Schema path for a quest completion reward bundle. + public static string ResolveQuestRewardBundleSchemaPath(string questsDirectory) => + Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-reward-bundle.schema.json")); } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs index 622e726..32b0430 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static class QuestCatalogServiceCollectionExtensions var itemCatalog = sp.GetRequiredService(); var recipeCatalog = sp.GetRequiredService(); var encounterCatalog = sp.GetRequiredService(); + var skillCatalog = sp.GetRequiredService(); var logger = sp.GetRequiredService() .CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog"); @@ -42,6 +43,9 @@ public static class QuestCatalogServiceCollectionExtensions questsDir, opts.QuestObjectiveDefSchemaPath, hostEnv.ContentRootPath); + var rewardGrantRowSchemaPath = QuestCatalogPathResolution.ResolveRewardGrantRowSchemaPath(questsDir); + var questSkillXpGrantSchemaPath = QuestCatalogPathResolution.ResolveQuestSkillXpGrantSchemaPath(questsDir); + var questRewardBundleSchemaPath = QuestCatalogPathResolution.ResolveQuestRewardBundleSchemaPath(questsDir); var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); @@ -52,9 +56,13 @@ public static class QuestCatalogServiceCollectionExtensions questDefSchemaPath, questStepDefSchemaPath, questObjectiveDefSchemaPath, + rewardGrantRowSchemaPath, + questSkillXpGrantSchemaPath, + questRewardBundleSchemaPath, knownItemIds, knownRecipeIds, knownEncounterIds, + skillCatalog.ById, logger); }); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs index 9456b65..984e314 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs @@ -5,7 +5,8 @@ public sealed class QuestDefRow( string id, string displayName, IReadOnlyList prerequisiteQuestIds, - IReadOnlyList steps) + IReadOnlyList steps, + QuestRewardBundleRow? completionRewardBundle = null) { public string Id { get; } = id; @@ -14,4 +15,6 @@ public sealed class QuestDefRow( public IReadOnlyList PrerequisiteQuestIds { get; } = prerequisiteQuestIds; public IReadOnlyList Steps { get; } = steps; + + public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle; } diff --git a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs index f570220..5d80031 100644 --- a/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs +++ b/server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs @@ -3,10 +3,12 @@ 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.Skills; namespace NeonSprawl.Server.Game.Quests; -/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113). +/// Loads and validates content/quests/*_quests.json using the same rules as scripts/validate_content.py (NEO-113, NEO-125). public static class QuestDefinitionCatalogLoader { private static JsonSchema? _questObjectiveDefSchema; @@ -18,15 +20,22 @@ public static class QuestDefinitionCatalogLoader string questDefSchemaPath, string questStepDefSchemaPath, string questObjectiveDefSchemaPath, + string rewardGrantRowSchemaPath, + string questSkillXpGrantSchemaPath, + string questRewardBundleSchemaPath, IReadOnlySet knownItemIds, IReadOnlySet knownRecipeIds, IReadOnlySet knownEncounterIds, + IReadOnlyDictionary skillDefsById, ILogger logger) { questsDirectory = Path.GetFullPath(questsDirectory); questDefSchemaPath = Path.GetFullPath(questDefSchemaPath); questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath); questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath); + rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath); + questSkillXpGrantSchemaPath = Path.GetFullPath(questSkillXpGrantSchemaPath); + questRewardBundleSchemaPath = Path.GetFullPath(questRewardBundleSchemaPath); var errors = new List(); @@ -39,6 +48,15 @@ public static class QuestDefinitionCatalogLoader if (!File.Exists(questObjectiveDefSchemaPath)) errors.Add($"error: missing schema file {questObjectiveDefSchemaPath}"); + 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 (!Directory.Exists(questsDirectory)) errors.Add($"error: missing directory {questsDirectory}"); @@ -54,7 +72,13 @@ public static class QuestDefinitionCatalogLoader if (errors.Count > 0) ThrowIfAny(errors); - EnsureQuestSchemasLoaded(questObjectiveDefSchemaPath, questStepDefSchemaPath, questDefSchemaPath); + EnsureQuestSchemasLoaded( + questObjectiveDefSchemaPath, + questStepDefSchemaPath, + rewardGrantRowSchemaPath, + questSkillXpGrantSchemaPath, + questRewardBundleSchemaPath, + questDefSchemaPath); var defSchema = _questDefSchema!; var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; @@ -169,6 +193,27 @@ public static class QuestDefinitionCatalogLoader ThrowIfAny(errors); } + var bundlePresence = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundlePresenceError(rows); + if (bundlePresence is not null) + { + errors.Add(bundlePresence); + ThrowIfAny(errors); + } + + var bundleFreeze = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleFreezeError(rows); + if (bundleFreeze is not null) + { + errors.Add(bundleFreeze); + ThrowIfAny(errors); + } + + var bundleCrossRef = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, skillDefsById); + if (bundleCrossRef is not null) + { + errors.Add(bundleCrossRef); + ThrowIfAny(errors); + } + if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation( @@ -260,7 +305,56 @@ public static class QuestDefinitionCatalogLoader var id = (rowObj["id"] as JsonValue)!.GetValue(); var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); var prerequisiteQuestIds = ParseStringArray(rowObj["prerequisiteQuestIds"] as JsonArray); - return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps); + var completionRewardBundle = ParseCompletionRewardBundle(rowObj["completionRewardBundle"] as JsonObject); + return new QuestDefRow(id, displayName, prerequisiteQuestIds, steps, 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); + return new QuestRewardBundleRow(itemGrants, skillXpGrants); + } + + 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 QuestStepDefRow ParseStep(JsonObject stepObj) @@ -345,15 +439,21 @@ public static class QuestDefinitionCatalogLoader } } - /// Registers objective → step → def in once per process (NEO-113). + /// Registers bundle + objective → step → def in once per process (NEO-113, NEO-125). private static void EnsureQuestSchemasLoaded( string questObjectiveDefSchemaPath, string questStepDefSchemaPath, + string rewardGrantRowSchemaPath, + string questSkillXpGrantSchemaPath, + string questRewardBundleSchemaPath, string questDefSchemaPath) { if (_questDefSchema is not null) return; + CatalogSchemaRegistry.GetOrRegisterFromFile(rewardGrantRowSchemaPath); + CatalogSchemaRegistry.GetOrRegisterFromFile(questSkillXpGrantSchemaPath); + CatalogSchemaRegistry.GetOrRegisterFromFile(questRewardBundleSchemaPath); _questObjectiveDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questObjectiveDefSchemaPath); _questStepDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questStepDefSchemaPath); _questDefSchema = CatalogSchemaRegistry.GetOrRegisterFromFile(questDefSchemaPath); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs new file mode 100644 index 0000000..da7bd1c --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestRewardBundleRow.cs @@ -0,0 +1,8 @@ +using NeonSprawl.Server.Game.Encounters; + +namespace NeonSprawl.Server.Game.Quests; + +/// Composite completion rewards on (NEO-125). +public sealed record QuestRewardBundleRow( + IReadOnlyList ItemGrants, + IReadOnlyList SkillXpGrants); diff --git a/server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs b/server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs new file mode 100644 index 0000000..81d1912 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Quests/QuestSkillXpGrantRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Quests; + +/// Single skill XP row on a quest (NEO-125). +public sealed record QuestSkillXpGrantRow(string SkillId, int Amount); diff --git a/server/README.md b/server/README.md index 49c7b56..5d82864 100644 --- a/server/README.md +++ b/server/README.md @@ -135,7 +135,7 @@ On success, **Information** logs include the resolved encounters directory path, ## Quest catalog (`content/quests`, NEO-113) -On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, and encounter catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`** and **`quest-objective-def.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, and enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate (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. +On startup the host loads every **`*_quests.json`** under the quests directory **after** the item, recipe, encounter, and **skill** catalogs, validates each row against **`content/schemas/quest-def.schema.json`** (with **`quest-step-def.schema.json`**, **`quest-objective-def.schema.json`**, **`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, and **`reward-grant-row.schema.json`** for nested `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate quest / step / objective `id`** values (step and objective uniqueness is per quest), cross-checks objective **`itemId`** / **`recipeId`** / **`encounterId`** against loaded catalogs, enforces the **prototype E7M1** four-quest roster, acyclic **`prerequisiteQuestIds`**, and operator-chain terminal **`inventory_has_item`** **`contract_handoff_token`** ×1 gate, and enforces **prototype E7M2** **`completionRewardBundle`** presence, freeze-table contents, item/skill cross-refs, and **`mission_reward`** allowlist on skill XP targets (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 | |--------|---------| @@ -144,7 +144,7 @@ On startup the host loads every **`*_quests.json`** under the quests directory * | **`Content:QuestStepDefSchemaPath`** | Optional override for **`quest-step-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-step-def.schema.json`**. | | **`Content:QuestObjectiveDefSchemaPath`** | Optional override for **`quest-objective-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-objective-def.schema.json`**. | -**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**), and the three quest schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. +**Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**), and quest/bundle schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. 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. From 8d026bdca46e85aa008413a9a86687b756e697da Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 15:25:58 -0400 Subject: [PATCH 3/5] NEO-125: document server bundle load in README and alignment docs --- .../modules/E7_M2_RewardAndUnlockRouter.md | 2 ++ .../documentation_and_implementation_alignment.md | 2 +- docs/plans/E7M2-prototype-backlog.md | 4 ++-- docs/plans/NEO-125-implementation-plan.md | 12 ++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md index ead7c10..262265b 100644 --- a/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md +++ b/docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md @@ -55,6 +55,8 @@ Epic 7 **Slice 2** — `reward_delivery`, `unlock_granted`; no double-claim on r **CI enforcement ([NEO-124](https://linear.app/neon-sprawl/issue/NEO-124)):** [`scripts/validate_content.py`](../../../scripts/validate_content.py) requires **`completionRewardBundle`** on all four frozen quest ids with exact freeze-table contents; cross-checks **`itemGrants[].itemId`** against the frozen item catalog and **`skillXpGrants[].skillId`** against skills that allow **`mission_reward`** in **`allowedXpSourceKinds`**. +**Server load ([NEO-125](https://linear.app/neon-sprawl/issue/NEO-125)):** [`QuestDefinitionCatalogLoader`](../../../server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs) mirrors the same E7M2 gates at startup via **`PrototypeE7M2QuestCatalogRules`**; see [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113). + ## 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 9b40c2f..5d9701e 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -59,7 +59,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | | E5.M3 | Ready | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. **E5M3-04 HTTP read ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)):** **`GET /game/world/encounter-definitions`** — `EncounterDefinitionsWorldApi` + nested **`rewardTable`** summary ([NEO-103](../../plans/NEO-103-implementation-plan.md)); [server README — Encounter definitions (NEO-103)](../../../server/README.md#encounter-definitions-neo-103); Bruno `bruno/neon-sprawl-server/encounter-definitions/`. **E5M3-05 stores ([NEO-104](https://linear.app/neon-sprawl/issue/NEO-104)):** **`IEncounterProgressStore`** + **`IEncounterCompletionStore`** + **`EncounterProgressOperations`** ([NEO-104](../../plans/NEO-104-implementation-plan.md)); [server README — Encounter progress (NEO-104)](../../../server/README.md#encounter-progress--completion-stores-neo-104). **E5M3-06 completion grants ([NEO-105](https://linear.app/neon-sprawl/issue/NEO-105)):** **`EncounterCompletionOperations`** + **`EncounterCompleteEvent`** result payload ([NEO-105](../../plans/NEO-105-implementation-plan.md)); [server README — Encounter completion (NEO-105)](../../../server/README.md#encounter-completion--inventory-grants-neo-105). **E5M3-07 combat wiring ([NEO-106](https://linear.app/neon-sprawl/issue/NEO-106)):** **`EncounterCombatWiring`** on **`AbilityCastApi`** ([NEO-106](../../plans/NEO-106-implementation-plan.md)); [server README — Encounter combat wiring (NEO-106)](../../../server/README.md#encounter-combat-wiring-neo-106). **E5M3-09 event record ([NEO-107](https://linear.app/neon-sprawl/issue/NEO-107)):** **`IEncounterCompleteEventStore`** + E7.M2 hook stub ([NEO-107](../../plans/NEO-107-implementation-plan.md)); [server README — Encounter complete event (NEO-107)](../../../server/README.md#encounter-complete-event-record-neo-107). **E5M3-08 per-player GET ([NEO-108](https://linear.app/neon-sprawl/issue/NEO-108)):** **`GET /game/players/{id}/encounter-progress`** — `EncounterProgressApi` + DTOs ([NEO-108](../../plans/NEO-108-implementation-plan.md)); [server README — Per-player encounter progress (NEO-108)](../../../server/README.md#per-player-encounter-progress-neo-108); Bruno `bruno/neon-sprawl-server/encounter-progress/`. **E5M3-10 telemetry ([NEO-109](https://linear.app/neon-sprawl/issue/NEO-109)):** comment-only **`encounter_start`**, **`encounter_complete`**, **`reward_attribution`**, **`encounter_complete_denied`** in **`EncounterProgressOperations`** / **`EncounterCompletionOperations`** ([NEO-109](../../plans/NEO-109-implementation-plan.md)); [server README — Encounter telemetry hooks (NEO-109)](../../../server/README.md#encounter-telemetry-hooks-neo-109). **E5M3-11 client HUD ([NEO-110](https://linear.app/neon-sprawl/issue/NEO-110)):** **`encounter_progress_client.gd`**, **`EncounterProgressLabel`** / **`EncounterCompleteLabel`**; boot + defeat-triggered GET; inventory refresh on **`completed`** ([NEO-110](../../plans/NEO-110-implementation-plan.md), [`NEO-110` manual QA](../../manual-qa/NEO-110.md)); [client README — Encounter progress + loot HUD (NEO-110)](../../../client/README.md#encounter-progress--loot-hud-neo-110). **E5M3-12 client capstone ([NEO-111](https://linear.app/neon-sprawl/issue/NEO-111)):** playable encounter clear loop — [`NEO-111` manual QA](../../manual-qa/NEO-111.md); [client README — End-to-end encounter clear loop (NEO-111)](../../../client/README.md#end-to-end-encounter-clear-loop-neo-111); plan [NEO-111](../../plans/NEO-111-implementation-plan.md). **Epic 5 Slice 3 client capstone complete.** Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) **landed**. Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-111 plan](../../plans/NEO-111-implementation-plan.md), [NEO-110 plan](../../plans/NEO-110-implementation-plan.md), [NEO-109 plan](../../plans/NEO-109-implementation-plan.md), [NEO-108 plan](../../plans/NEO-108-implementation-plan.md), [NEO-107 plan](../../plans/NEO-107-implementation-plan.md), [NEO-106 plan](../../plans/NEO-106-implementation-plan.md), [NEO-105 plan](../../plans/NEO-105-implementation-plan.md), [NEO-104 plan](../../plans/NEO-104-implementation-plan.md), [NEO-103 plan](../../plans/NEO-103-implementation-plan.md), [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | -| E7.M2 | Planned | **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. **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-02** [NEO-125](https://linear.app/neon-sprawl/issue/NEO-125) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Idempotent **`IRewardDeliveryStore`**; wire **`RewardRouterOperations`** on quest complete; extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md), [E7_M2](E7_M2_RewardAndUnlockRouter.md), [NEO-124](../../plans/NEO-124-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), label **`E7.M2`** on NEO-124–NEO-132 | +| E7.M2 | Planned | **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). **Backlog decomposed:** Epic 7 Slice 2 — [E7M2-prototype-backlog.md](../../plans/E7M2-prototype-backlog.md) **E7M2-03** [NEO-126](https://linear.app/neon-sprawl/issue/NEO-126) → **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132); label **`E7.M2`**. Idempotent **`IRewardDeliveryStore`**; wire **`RewardRouterOperations`** on quest complete; extend quest-progress GET with **`completionRewardSummary`**; client capstone **E7M2-09** [NEO-132](https://linear.app/neon-sprawl/issue/NEO-132). **NEO-43 prep landed:** **`MissionRewardSkillXpGrant`**. **Encounter loot unchanged** (E5.M3 direct grants). Upstream: E7.M1 **Ready**, E2.M2 grant stack, E3.M3 inventory **Ready**. Register row stays **Planned** until capstone **NEO-132**. | [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-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 | --- diff --git a/docs/plans/E7M2-prototype-backlog.md b/docs/plans/E7M2-prototype-backlog.md index 2220d7a..a406529 100644 --- a/docs/plans/E7M2-prototype-backlog.md +++ b/docs/plans/E7M2-prototype-backlog.md @@ -122,8 +122,8 @@ Combat intro bundle is **skill XP only** — encounter loot already granted **`c **Acceptance criteria** -- [ ] Host exits on invalid bundle refs at startup. -- [ ] `dotnet test` covers loader gates. +- [x] Host exits on invalid bundle refs at startup. +- [x] `dotnet test` covers loader gates. **Client counterpart:** none (infrastructure-only). diff --git a/docs/plans/NEO-125-implementation-plan.md b/docs/plans/NEO-125-implementation-plan.md index 814154c..125f859 100644 --- a/docs/plans/NEO-125-implementation-plan.md +++ b/docs/plans/NEO-125-implementation-plan.md @@ -45,8 +45,16 @@ ## Acceptance criteria checklist -- [ ] Host exits on invalid bundle refs at startup. -- [ ] `dotnet test` covers loader gates. +- [x] Host exits on invalid bundle refs at startup. +- [x] `dotnet test` covers loader gates. + +## Implementation reconciliation (shipped) + +- **DTOs:** `QuestSkillXpGrantRow`, `QuestRewardBundleRow`; optional **`CompletionRewardBundle`** on **`QuestDefRow`**. +- **Rules:** `PrototypeE7M2QuestCatalogRules` — presence, freeze-table, cross-ref + **`mission_reward`** allowlist gates synced to `validate_content.py`. +- **Loader:** registers bundle-related JSON Schemas; parses **`completionRewardBundle`**; accepts **`SkillDefinitionCatalog.ById`** from DI. +- **Tests:** 25 AAA tests in **`QuestDefinitionCatalogLoaderTests`** (5 new E7M2 cases + updated prototype fixture with bundles). +- **Docs:** `server/README.md` quest catalog E7M2 paragraph; E7M2-02 backlog AC checked. ## Technical approach From ad80ed8760af1947865a279b3128650a176cbbf8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 15:26:51 -0400 Subject: [PATCH 4/5] NEO-125: add code review for quest bundle catalog load --- docs/reviews/2026-06-07-NEO-125.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/reviews/2026-06-07-NEO-125.md diff --git a/docs/reviews/2026-06-07-NEO-125.md b/docs/reviews/2026-06-07-NEO-125.md new file mode 100644 index 0000000..2dd0883 --- /dev/null +++ b/docs/reviews/2026-06-07-NEO-125.md @@ -0,0 +1,54 @@ +# Code review — NEO-125 (E7M2-02) + +**Date:** 2026-06-07 +**Scope:** Branch `NEO-125-e7m2-server-quest-catalog-reward-bundle-validation` vs `origin/main` — commits `47ddde6` … `8d026bd` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** — implementation matches plan and CI parity; tests pass; minor test-naming/coverage notes only. + +## Summary + +NEO-125 closes the server-side gap left by NEO-124: the quest catalog loader now registers bundle-related JSON Schemas (`reward-grant-row`, `quest-skill-xp-grant`, `quest-reward-bundle`), parses **`completionRewardBundle`** onto **`QuestDefRow`**, and runs **`PrototypeE7M2QuestCatalogRules`** (presence → freeze → cross-ref + **`mission_reward`** allowlist) at startup — mirroring **`scripts/validate_content.py`**. DI injects **`SkillDefinitionCatalog`** so skill allowlist checks use loaded defs. Five new loader tests extend the NEO-113 fixture pattern; host boot via **`WebApplicationFactory`** still resolves the catalog. Risk is low: infrastructure-only, fail-fast at startup, no HTTP or delivery changes. + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-125-implementation-plan.md` | **Matches** — kickoff decisions, acceptance criteria checked, reconciliation accurate. | +| `docs/plans/E7M2-prototype-backlog.md` (E7M2-02) | **Matches** — AC checkboxes complete; infrastructure-only client counterpart noted. | +| `docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md` | **Matches** — server load paragraph added; freeze table unchanged. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M2 row notes NEO-125 server load; row stays **Planned** until NEO-132 capstone. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M2 note references NEO-125; register row correctly **Planned**. | +| `server/README.md` | **Matches** — quest catalog section documents E7M2 gates, bundle schemas, skill catalog dependency. | +| Full-stack epic decomposition | **N/A** — E7M2-02 is infrastructure-only; no client counterpart required. | + +**Register / tracking:** E7.M2 correctly remains **Planned** until capstone NEO-132; alignment updates are appropriate. + +## Blocking issues + +None. + +## Suggestions + +1. **Rename misleading bundle cross-ref tests** — `Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle` and `Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle` mutate bundle ids so the **freeze** gate fails first (assertions correctly expect `"completionRewardBundle must match E7M2 freeze table"`). Consider renaming to `…WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable` / `…SkillIdDoesNotMatchFreezeTable`, or add a focused unit test on **`PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError`** with a matching-freeze bundle to assert the cross-ref message paths (`is not in the frozen prototype item catalog` / skill catalog). + +2. **Manual boot smoke (plan §Tests)** — Plan lists booting the dev server and confirming the quest catalog Information log; worth a quick run before merge since NEO-124 content now depends on bundle schema registration at host startup. + +## Nits + +- Nit: Bundle schema paths use sibling `content/schemas/` resolution only; quest def/step/objective schemas support **`Content:*`** overrides. Plan marked overrides as optional — fine for prototype, but document if custom content layouts need the same override pattern later. +- Nit: In **`TryGetCompletionBundleCrossRefError`**, a skill id present in **`PrototypeSlice1SkillCatalogRules.ExpectedSkillIds`** but missing from **`skillDefsById`** returns the “not in the frozen prototype skill catalog” message; Python’s cross-ref gate would report a **`mission_reward`** allowlist failure instead. Unreachable in production DI (skills load before quests) but slightly divergent for isolated unit tests. + +## Verification + +```bash +dotnet test server/NeonSprawl.Server.Tests --filter QuestDefinitionCatalogLoaderTests +python3 scripts/validate_content.py +``` + +Optional before merge: + +- Boot dev server; confirm Information log for quest catalog load (4 quests) with no startup exception. +- If renaming tests (Suggestion **1**), re-run the filter above. From 766837d7e892628504bd32eb196f4d7ef647acda Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 15:27:55 -0400 Subject: [PATCH 5/5] NEO-125: address code review findings for bundle validation Rename freeze-first loader tests, add PrototypeE7M2QuestCatalogRulesTests for cross-ref paths, align missing skill-def error with Python, and document bundle schema path resolution in server README. --- docs/plans/NEO-125-implementation-plan.md | 7 +- docs/reviews/2026-06-07-NEO-125.md | 8 +- .../PrototypeE7M2QuestCatalogRulesTests.cs | 95 +++++++++++++++++++ .../QuestDefinitionCatalogLoaderTests.cs | 4 +- .../Quests/PrototypeE7M2QuestCatalogRules.cs | 14 +-- server/README.md | 2 + 6 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Quests/PrototypeE7M2QuestCatalogRulesTests.cs diff --git a/docs/plans/NEO-125-implementation-plan.md b/docs/plans/NEO-125-implementation-plan.md index 125f859..08cfec1 100644 --- a/docs/plans/NEO-125-implementation-plan.md +++ b/docs/plans/NEO-125-implementation-plan.md @@ -53,7 +53,7 @@ - **DTOs:** `QuestSkillXpGrantRow`, `QuestRewardBundleRow`; optional **`CompletionRewardBundle`** on **`QuestDefRow`**. - **Rules:** `PrototypeE7M2QuestCatalogRules` — presence, freeze-table, cross-ref + **`mission_reward`** allowlist gates synced to `validate_content.py`. - **Loader:** registers bundle-related JSON Schemas; parses **`completionRewardBundle`**; accepts **`SkillDefinitionCatalog.ById`** from DI. -- **Tests:** 25 AAA tests in **`QuestDefinitionCatalogLoaderTests`** (5 new E7M2 cases + updated prototype fixture with bundles). +- **Tests:** 25 AAA tests in **`QuestDefinitionCatalogLoaderTests`** (5 E7M2 loader cases + updated prototype fixture with bundles); **`PrototypeE7M2QuestCatalogRulesTests`** (4 direct cross-ref / allowlist cases). - **Docs:** `server/README.md` quest catalog E7M2 paragraph; E7M2-02 backlog AC checked. ## Technical approach @@ -148,8 +148,9 @@ Extend **`EnsureQuestSchemasLoaded`** signature to accept bundle schema paths; v | Target | Coverage | |--------|----------| -| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | Happy path (repo + temp fixture with bundles); E7M2 presence, freeze, unknown item/skill, missing **`mission_reward`** allowlist; existing E7M1 tests unchanged; host DI resolve. | -| **No new test files** | Extend existing loader test class per NEO-113 precedent. | +| `server/NeonSprawl.Server.Tests/Game/Quests/PrototypeE7M2QuestCatalogRulesTests.cs` | Direct unit tests for **`TryGetCompletionBundleCrossRefError`** (item/skill catalog + allowlist paths). | +| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | Happy path (repo + temp fixture with bundles); E7M2 presence, freeze, allowlist deny; host DI resolve. | +| **No other new test files** | Loader + rules classes per NEO-113 precedent. | | **Manual verification** | `dotnet test server/NeonSprawl.Server.Tests --filter QuestDefinitionCatalogLoaderTests`; boot dev server and confirm quest catalog Information log after NEO-124 content. | ## Open questions / risks diff --git a/docs/reviews/2026-06-07-NEO-125.md b/docs/reviews/2026-06-07-NEO-125.md index 2dd0883..4ec10ae 100644 --- a/docs/reviews/2026-06-07-NEO-125.md +++ b/docs/reviews/2026-06-07-NEO-125.md @@ -32,14 +32,14 @@ None. ## Suggestions -1. **Rename misleading bundle cross-ref tests** — `Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle` and `Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle` mutate bundle ids so the **freeze** gate fails first (assertions correctly expect `"completionRewardBundle must match E7M2 freeze table"`). Consider renaming to `…WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable` / `…SkillIdDoesNotMatchFreezeTable`, or add a focused unit test on **`PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError`** with a matching-freeze bundle to assert the cross-ref message paths (`is not in the frozen prototype item catalog` / skill catalog). +1. ~~**Rename misleading bundle cross-ref tests** — `Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle` and `Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle` mutate bundle ids so the **freeze** gate fails first (assertions correctly expect `"completionRewardBundle must match E7M2 freeze table"`). Consider renaming to `…WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable` / `…SkillIdDoesNotMatchFreezeTable`, or add a focused unit test on **`PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError`** with a matching-freeze bundle to assert the cross-ref message paths (`is not in the frozen prototype item catalog` / skill catalog).~~ **Done.** Renamed loader tests; added **`PrototypeE7M2QuestCatalogRulesTests`** with direct cross-ref coverage. -2. **Manual boot smoke (plan §Tests)** — Plan lists booting the dev server and confirming the quest catalog Information log; worth a quick run before merge since NEO-124 content now depends on bundle schema registration at host startup. +2. ~~**Manual boot smoke (plan §Tests)** — Plan lists booting the dev server and confirming the quest catalog Information log; worth a quick run before merge since NEO-124 content now depends on bundle schema registration at host startup.~~ **Done.** Dev server boot logged `Loaded quest catalog from …/content/quests: 4 quest(s) across 1 JSON catalog file(s).` with no startup exception. ## Nits -- Nit: Bundle schema paths use sibling `content/schemas/` resolution only; quest def/step/objective schemas support **`Content:*`** overrides. Plan marked overrides as optional — fine for prototype, but document if custom content layouts need the same override pattern later. -- Nit: In **`TryGetCompletionBundleCrossRefError`**, a skill id present in **`PrototypeSlice1SkillCatalogRules.ExpectedSkillIds`** but missing from **`skillDefsById`** returns the “not in the frozen prototype skill catalog” message; Python’s cross-ref gate would report a **`mission_reward`** allowlist failure instead. Unreachable in production DI (skills load before quests) but slightly divergent for isolated unit tests. +- Nit: Bundle schema paths use sibling `content/schemas/` resolution only; quest def/step/objective schemas support **`Content:*`** overrides. Plan marked overrides as optional — fine for prototype; **`server/README.md`** now documents that bundle schemas have no separate override keys yet. +- ~~Nit: In **`TryGetCompletionBundleCrossRefError`**, a skill id present in **`PrototypeSlice1SkillCatalogRules.ExpectedSkillIds`** but missing from **`skillDefsById`** returns the “not in the frozen prototype skill catalog” message; Python’s cross-ref gate would report a **`mission_reward`** allowlist failure instead. Unreachable in production DI (skills load before quests) but slightly divergent for isolated unit tests.~~ **Done.** Missing loaded skill defs now mirror Python — empty **`allowedXpSourceKinds`** → **`mission_reward`** allowlist error. ## Verification diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/PrototypeE7M2QuestCatalogRulesTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/PrototypeE7M2QuestCatalogRulesTests.cs new file mode 100644 index 0000000..6e0f1b7 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Quests/PrototypeE7M2QuestCatalogRulesTests.cs @@ -0,0 +1,95 @@ +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Game.Quests; +using NeonSprawl.Server.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Quests; + +public class PrototypeE7M2QuestCatalogRulesTests +{ + private static readonly IReadOnlyList EmptySteps = + [new QuestStepDefRow("step", "Step", [])]; + + private static QuestDefRow QuestWithBundle(string questId, QuestRewardBundleRow bundle) => + new(questId, "Test", [], EmptySteps, bundle); + + private static readonly IReadOnlyDictionary ValidSkillDefs = + new Dictionary(StringComparer.Ordinal) + { + ["salvage"] = new("salvage", "gather", "Salvage", ["activity", "mission_reward"]), + ["refine"] = new("refine", "process", "Refine", ["activity", "mission_reward", "trainer"]), + }; + + [Fact] + public void TryGetCompletionBundleCrossRefError_ShouldReturnError_WhenItemIdNotInFrozenCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeE7M1QuestCatalogRules.ChainQuestId] = QuestWithBundle( + PrototypeE7M1QuestCatalogRules.ChainQuestId, + new QuestRewardBundleRow( + [new RewardGrantRow("unknown_item_id", 1)], + [new QuestSkillXpGrantRow("salvage", 50)])), + }; + // Act + var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, ValidSkillDefs); + // Assert + Assert.NotNull(error); + Assert.Contains("completionRewardBundle.itemGrants[0]: itemId 'unknown_item_id'", error, StringComparison.Ordinal); + Assert.Contains("is not in the frozen prototype item catalog", error, StringComparison.Ordinal); + } + + [Fact] + public void TryGetCompletionBundleCrossRefError_ShouldReturnError_WhenSkillIdNotInFrozenCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = QuestWithBundle( + PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, + new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("unknown_skill_id", 25)])), + }; + // Act + var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, ValidSkillDefs); + // Assert + Assert.NotNull(error); + Assert.Contains("completionRewardBundle.skillXpGrants[0]: skillId 'unknown_skill_id'", error, StringComparison.Ordinal); + Assert.Contains("is not in the frozen prototype skill catalog", error, StringComparison.Ordinal); + } + + [Fact] + public void TryGetCompletionBundleCrossRefError_ShouldReturnError_WhenSkillMissingFromLoadedDefs() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = QuestWithBundle( + PrototypeE7M1QuestCatalogRules.GatherIntroQuestId, + new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)])), + }; + // Act + var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError( + rows, + new Dictionary(StringComparer.Ordinal)); + // Assert + Assert.NotNull(error); + Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds (got [])", error, StringComparison.Ordinal); + } + + [Fact] + public void TryGetCompletionBundleCrossRefError_ShouldReturnNull_WhenBundlesMatchFreezeAndAllowlists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal); + foreach (var (qid, bundle) in PrototypeE7M2QuestCatalogRules.ExpectedCompletionBundles) + { + rows[qid] = QuestWithBundle(qid, bundle); + } + + // Act + var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(rows, ValidSkillDefs); + // Assert + Assert.Null(error); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs index d74eafc..37ead2a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs @@ -645,7 +645,7 @@ public class QuestDefinitionCatalogLoaderTests } [Fact] - public void Load_ShouldThrow_WhenUnknownItemIdInCompletionRewardBundle() + public void Load_ShouldThrow_WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable() { // Arrange var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); @@ -667,7 +667,7 @@ public class QuestDefinitionCatalogLoaderTests } [Fact] - public void Load_ShouldThrow_WhenUnknownSkillIdInCompletionRewardBundle() + public void Load_ShouldThrow_WhenCompletionRewardBundleSkillIdDoesNotMatchFreezeTable() { // Arrange var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout(); diff --git a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs index ad74153..959425f 100644 --- a/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs +++ b/server/NeonSprawl.Server/Game/Quests/PrototypeE7M2QuestCatalogRules.cs @@ -103,17 +103,13 @@ public static class PrototypeE7M2QuestCatalogRules $"'{grant.SkillId}' is not in the frozen prototype skill catalog"; } - if (!skillDefsById.TryGetValue(grant.SkillId, out var skill)) - { - return - $"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + - $"'{grant.SkillId}' is not in the frozen prototype skill catalog"; - } - - var allowedKinds = skill.AllowedXpSourceKinds; + skillDefsById.TryGetValue(grant.SkillId, out var skill); + var allowedKinds = skill?.AllowedXpSourceKinds ?? []; if (!allowedKinds.Contains(MissionRewardSourceKind, StringComparer.Ordinal)) { - var kindsDisplay = $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]"; + var kindsDisplay = allowedKinds.Count == 0 + ? "[]" + : $"[{string.Join(", ", allowedKinds.Select(k => $"'{k}'"))}]"; return $"error: quest '{qid}' completionRewardBundle.skillXpGrants[{gi}]: skillId " + $"'{grant.SkillId}' must allow sourceKind '{MissionRewardSourceKind}' in allowedXpSourceKinds " + diff --git a/server/README.md b/server/README.md index 5d82864..179a294 100644 --- a/server/README.md +++ b/server/README.md @@ -144,6 +144,8 @@ On startup the host loads every **`*_quests.json`** under the quests directory * | **`Content:QuestStepDefSchemaPath`** | Optional override for **`quest-step-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-step-def.schema.json`**. | | **`Content:QuestObjectiveDefSchemaPath`** | Optional override for **`quest-objective-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-objective-def.schema.json`**. | +Bundle-related schemas (**`quest-reward-bundle.schema.json`**, **`quest-skill-xp-grant.schema.json`**, **`reward-grant-row.schema.json`**) resolve as siblings under **`{parent of quests directory}/schemas/`** only — there are no separate **`Content:*`** override keys yet; custom layouts must keep those three files next to the quest schemas (NEO-125). + **Docker / CI:** include **`content/quests`**, upstream catalogs (**`content/items`**, **`content/recipes`**, **`content/encounters`**, **`content/skills`**), and quest/bundle schemas in the mounted **`content/`** tree; set **`Content__QuestsDirectory`** when layout differs. 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.