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 validationpull/165/head
commit
0372991d87
|
|
@ -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
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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; 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
|
||||
|
||||
```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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue