Merge pull request #164 from ViPro-Technologies/NEO-125-e7m2-server-quest-catalog-reward-bundle-validation

NEO-125: E7M2-02 server quest catalog reward bundle validation
pull/165/head
VinPropane 2026-06-07 15:32:01 -04:00 committed by GitHub
commit 0372991d87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 794 additions and 11 deletions

View File

@ -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.

File diff suppressed because one or more lines are too long

View File

@ -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).

View File

@ -0,0 +1,165 @@
# 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
- [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 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
### 1. Bundle DTOs (`Game/Quests/`)
- **`QuestSkillXpGrantRow`** — `SkillId`, `Amount` (record or sealed class, same style as `QuestObjectiveDefRow`).
- **`QuestRewardBundleRow`** — `ItemGrants` (`IReadOnlyList<RewardGrantRow>` from `Game.Encounters`), `SkillXpGrants` (`IReadOnlyList<QuestSkillXpGrantRow>`).
- 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<string, SkillDefRow> 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/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
| 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.

View File

@ -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).~~ **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.~~ **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; **`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; Pythons 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
```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.

View File

@ -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<QuestStepDefRow> EmptySteps =
[new QuestStepDefRow("step", "Step", [])];
private static QuestDefRow QuestWithBundle(string questId, QuestRewardBundleRow bundle) =>
new(questId, "Test", [], EmptySteps, bundle);
private static readonly IReadOnlyDictionary<string, SkillDefRow> ValidSkillDefs =
new Dictionary<string, SkillDefRow>(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<string, QuestDefRow>(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<string, QuestDefRow>(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<string, QuestDefRow>(StringComparer.Ordinal)
{
[PrototypeE7M1QuestCatalogRules.GatherIntroQuestId] = QuestWithBundle(
PrototypeE7M1QuestCatalogRules.GatherIntroQuestId,
new QuestRewardBundleRow([], [new QuestSkillXpGrantRow("salvage", 25)])),
};
// Act
var error = PrototypeE7M2QuestCatalogRules.TryGetCompletionBundleCrossRefError(
rows,
new Dictionary<string, SkillDefRow>(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<string, QuestDefRow>(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);
}
}

View File

@ -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());
}

View File

@ -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<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
private static readonly IReadOnlyDictionary<string, SkillDefRow> RepoSkillDefs = LoadRepoSkillDefs();
private static IReadOnlyDictionary<string, SkillDefRow> 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<string, SkillDefRow>? 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<InvalidOperationException>(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<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenCompletionRewardBundleItemIdDoesNotMatchFreezeTable()
{
// 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<InvalidOperationException>(ex);
Assert.Contains("completionRewardBundle must match E7M2 freeze table", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenCompletionRewardBundleSkillIdDoesNotMatchFreezeTable()
{
// 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<InvalidOperationException>(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<string, SkillDefRow>(RepoSkillDefs, StringComparer.Ordinal)
{
["salvage"] = salvageNoMissionReward,
};
// Act
var ex = Record.Exception(() =>
LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath, skillDefs));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("must allow sourceKind 'mission_reward' in allowedXpSourceKinds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingSchemaFile()
{

View File

@ -0,0 +1,179 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>
/// Prototype E7M2 completion bundle gates (NEO-125), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_*</c> and <c>_prototype_e7m2_*</c> gate functions.
/// </summary>
public static class PrototypeE7M2QuestCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND</c>.</summary>
public const string MissionRewardSourceKind = "mission_reward";
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M2_COMPLETION_BUNDLES</c>.</summary>
public static readonly FrozenDictionary<string, QuestRewardBundleRow> ExpectedCompletionBundles =
new Dictionary<string, QuestRewardBundleRow>(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);
/// <summary>Returns a human-readable error if a frozen quest lacks a completion bundle, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundlePresenceError(IReadOnlyDictionary<string, QuestDefRow> 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;
}
/// <summary>Returns a human-readable error if bundle contents diverge from the E7M2 freeze table, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundleFreezeError(IReadOnlyDictionary<string, QuestDefRow> 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;
}
/// <summary>Returns a human-readable error when bundle cross-refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetCompletionBundleCrossRefError(
IReadOnlyDictionary<string, QuestDefRow> rowsById,
IReadOnlyDictionary<string, SkillDefRow> 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";
}
skillDefsById.TryGetValue(grant.SkillId, out var skill);
var allowedKinds = skill?.AllowedXpSourceKinds ?? [];
if (!allowedKinds.Contains(MissionRewardSourceKind, StringComparer.Ordinal))
{
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 " +
$"(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);
}

View File

@ -93,4 +93,16 @@ public static class QuestCatalogPathResolution
return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json"));
}
/// <summary>Resolves JSON Schema path for a reward grant row (shared by reward tables and quest bundles).</summary>
public static string ResolveRewardGrantRowSchemaPath(string questsDirectory) =>
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "reward-grant-row.schema.json"));
/// <summary>Resolves JSON Schema path for a quest skill XP grant row.</summary>
public static string ResolveQuestSkillXpGrantSchemaPath(string questsDirectory) =>
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-skill-xp-grant.schema.json"));
/// <summary>Resolves JSON Schema path for a quest completion reward bundle.</summary>
public static string ResolveQuestRewardBundleSchemaPath(string questsDirectory) =>
Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-reward-bundle.schema.json"));
}

View File

@ -24,6 +24,7 @@ public static class QuestCatalogServiceCollectionExtensions
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.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);
});

View File

@ -5,7 +5,8 @@ public sealed class QuestDefRow(
string id,
string displayName,
IReadOnlyList<string> prerequisiteQuestIds,
IReadOnlyList<QuestStepDefRow> steps)
IReadOnlyList<QuestStepDefRow> steps,
QuestRewardBundleRow? completionRewardBundle = null)
{
public string Id { get; } = id;
@ -14,4 +15,6 @@ public sealed class QuestDefRow(
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
public QuestRewardBundleRow? CompletionRewardBundle { get; } = completionRewardBundle;
}

View File

@ -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;
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113).</summary>
/// <summary>Loads and validates <c>content/quests/*_quests.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-113, NEO-125).</summary>
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<string> knownItemIds,
IReadOnlySet<string> knownRecipeIds,
IReadOnlySet<string> knownEncounterIds,
IReadOnlyDictionary<string, SkillDefRow> 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<string>();
@ -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<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
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<RewardGrantRow> ParseItemGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<RewardGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue<string>();
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue<int>();
rows.Add(new RewardGrantRow(itemId, quantity));
}
return rows;
}
private static IReadOnlyList<QuestSkillXpGrantRow> ParseSkillXpGrantRows(JsonArray? array)
{
if (array is null || array.Count == 0)
return [];
var rows = new List<QuestSkillXpGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var skillId = (grantObj["skillId"] as JsonValue)!.GetValue<string>();
var amount = (grantObj["amount"] as JsonValue)!.GetValue<int>();
rows.Add(new QuestSkillXpGrantRow(skillId, amount));
}
return rows;
}
private static QuestStepDefRow ParseStep(JsonObject stepObj)
@ -345,15 +439,21 @@ public static class QuestDefinitionCatalogLoader
}
}
/// <summary>Registers objective → step → def in <see cref="SchemaRegistry.Global"/> once per process (NEO-113).</summary>
/// <summary>Registers bundle + objective → step → def in <see cref="SchemaRegistry.Global"/> once per process (NEO-113, NEO-125).</summary>
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);

View File

@ -0,0 +1,8 @@
using NeonSprawl.Server.Game.Encounters;
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Composite completion rewards on <see cref="QuestDefRow.CompletionRewardBundle"/> (NEO-125).</summary>
public sealed record QuestRewardBundleRow(
IReadOnlyList<RewardGrantRow> ItemGrants,
IReadOnlyList<QuestSkillXpGrantRow> SkillXpGrants);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Quests;
/// <summary>Single skill XP row on a quest <see cref="QuestRewardBundleRow"/> (NEO-125).</summary>
public sealed record QuestSkillXpGrantRow(string SkillId, int Amount);

View File

@ -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,9 @@ 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.
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.