Merge pull request #152 from ViPro-Technologies/NEO-113-e7m1-server-quest-catalog-load-fail-fast
NEO-113: E7M1-02 server quest catalog load (fail-fast)pull/153/head
commit
d236aee261
|
|
@ -0,0 +1,25 @@
|
||||||
|
meta {
|
||||||
|
name: GET health (quest catalog boot NEO-113)
|
||||||
|
type: http
|
||||||
|
seq: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{baseUrl}}/health
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
|
|
||||||
|
docs {
|
||||||
|
NEO-113 loads content/quests/*_quests.json at startup (fail-fast). No quest HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||||
|
}
|
||||||
|
|
||||||
|
tests {
|
||||||
|
test("status 200", function () {
|
||||||
|
expect(res.getStatus()).to.equal(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("service identity", function () {
|
||||||
|
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
meta {
|
||||||
|
name: quest-catalog
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
| **Module ID** | E7.M1 |
|
| **Module ID** | E7.M1 |
|
||||||
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
| **Epic** | [Epic 7 — Quest / Faction](../epics/epic_07_quest_faction.md) |
|
||||||
| **Stage target** | Prototype |
|
| **Stage target** | Prototype |
|
||||||
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); runtime from **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
| **Status** | Planned — Slice 1 backlog [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md): **E7M1-01** [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) catalog **landed** (schemas + CI); **E7M1-02** [NEO-113](https://linear.app/neon-sprawl/issue/NEO-113) server load **landed**; registry + runtime from **E7M1-03** [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) → capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||||
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
|
| **Linear** | Label **`E7.M1`** · [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md) |
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
@ -78,6 +78,8 @@ The **first shipped quest spine** is **frozen** for prototype tuning until a del
|
||||||
|
|
||||||
**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
|
**CI enforcement (NEO-112):** `scripts/validate_content.py` requires **exactly** these four quest ids; objective cross-refs to frozen item/recipe/encounter catalogs; acyclic **`prerequisiteQuestIds`**; chain quest terminal step uses **`contract_handoff_token`** from [E5.M3 encounter loot](E5_M3_EncounterAndRewardTables.md#prototype-slice-3-freeze-e5m3-01).
|
||||||
|
|
||||||
|
**Server load (NEO-113):** Host fail-fast load of `content/quests/*_quests.json` with the same gates — [server README — Quest catalog](../../../server/README.md#quest-catalog-contentquests-neo-113).
|
||||||
|
|
||||||
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
|
**Reward policy (Slice 1):** Quest completion updates **`QuestStepState` only** — no duplicate item/XP grants. Gather/craft/encounter paths keep existing payouts; [E7.M2](E7_M2_RewardAndUnlockRouter.md) adds **`QuestRewardBundle`** apply in Slice 2.
|
||||||
|
|
||||||
## Risks and telemetry
|
## Risks and telemetry
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -109,7 +109,7 @@ Gameplay **content and curves** default to **boot load** with optional **dev-onl
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
|
| E7.M1 | QuestStateMachine | E3.M2, E5.M1 | QuestDef, QuestStepState, QuestStateTransition | Prototype | Planned |
|
||||||
|
|
||||||
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); register row stays **Planned** until **NEO-113** server load lands.
|
**E7.M1 note:** Epic 7 **Slice 1** backlog in Linear ([Epic 7 — Questing, Narrative, and Faction Reputation](https://linear.app/neon-sprawl/project/epic-7-questing-narrative-and-faction-reputation-a9416783ceee)): [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) → [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123); label **`E7.M1`**. See [E7M1-prototype-backlog.md](../../plans/E7M1-prototype-backlog.md), [E7_M1_QuestStateMachine.md](E7_M1_QuestStateMachine.md). Upstream: E3.M1 gather **Ready**, E3.M2 craft **Ready**, E5.M3 encounter **Ready**. Client capstone **E7M1-12** [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123). **E7M1-01 / NEO-112** catalog landed (schemas + CI); **E7M1-02 / NEO-113** server load landed; register row stays **Planned** until **NEO-114** registry + quest runtime land.
|
||||||
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
|
| E7.M2 | RewardAndUnlockRouter | E2.M2, E3.M3, E7.M1 | QuestRewardBundle, UnlockGrant, RewardDeliveryEvent | Prototype | Planned |
|
||||||
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
|
| E7.M3 | FactionReputationLedger | E7.M1 | FactionStanding, ReputationDelta, FactionGateRule | Pre-production | Planned |
|
||||||
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
| E7.M4 | ContractMissionGenerator | E4.M1, E5.M3, E7.M3 | ContractTemplate, ContractSeed, ContractOutcome | Pre-production | Planned |
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,8 @@ Working backlog for **Epic 7 — Slice 1** ([quest core and persistence](../deco
|
||||||
|
|
||||||
**Acceptance criteria**
|
**Acceptance criteria**
|
||||||
|
|
||||||
- [ ] Host fails startup on invalid quest JSON (mirror CI rules).
|
- [x] Host fails startup on invalid quest JSON (mirror CI rules).
|
||||||
- [ ] Loader tests cover allowlist + cross-ref failures.
|
- [x] Loader tests cover allowlist + cross-ref failures.
|
||||||
|
|
||||||
**Client counterpart:** none (infrastructure-only).
|
**Client counterpart:** none (infrastructure-only).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
# NEO-113 — Implementation plan
|
||||||
|
|
||||||
|
## Story reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------|--------|
|
||||||
|
| **Key** | NEO-113 |
|
||||||
|
| **Title** | E7M1-02: Server quest catalog load (fail-fast) |
|
||||||
|
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-113/e7m1-02-server-quest-catalog-load-fail-fast |
|
||||||
|
| **Module** | [E7.M1 — QuestStateMachine](../decomposition/modules/E7_M1_QuestStateMachine.md) · Epic 7 Slice 1 · backlog **E7M1-02** |
|
||||||
|
| **Branch** | `NEO-113-e7m1-server-quest-catalog-load-fail-fast` |
|
||||||
|
| **Precursor** | [NEO-112](https://linear.app/neon-sprawl/issue/NEO-112) — quest schemas, `prototype_quests.json`, CI gates (**landed on `main`**) |
|
||||||
|
| **Pattern** | [NEO-101](NEO-101-implementation-plan.md) — row JSON Schema + catalog envelope + in-process C# gates mirroring `scripts/validate_content.py` |
|
||||||
|
| **Blocks** | [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) — injectable quest definition registry + DI |
|
||||||
|
| **Client counterpart** | None — server-only catalog load; player-visible work starts at [NEO-122](https://linear.app/neon-sprawl/issue/NEO-122) / [NEO-123](https://linear.app/neon-sprawl/issue/NEO-123) |
|
||||||
|
|
||||||
|
## Kickoff clarifications
|
||||||
|
|
||||||
|
| Topic | Question | Agent recommendation | Answer |
|
||||||
|
|--------|----------|----------------------|--------|
|
||||||
|
| **Test scope** | Loader unit tests only, or also `WebApplicationFactory` host boot failure / DI resolve tests? | **Both** — mirror [NEO-101](NEO-101-implementation-plan.md); Linear AC “host fails on invalid quest JSON” is stronger with an integration boot test. | **User:** loader + host boot tests. |
|
||||||
|
| **NEO-113 vs NEO-114 split** | Ship `IQuestDefinitionRegistry` on this story? | **Strict split** — E7M1-02: loader + catalog singleton + eager fail-fast boot; [NEO-114](https://linear.app/neon-sprawl/issue/NEO-114) adds injectable registry (E7M1-03). Same pattern as NEO-101/102. | **Adopted** — backlog out-of-scope; no separate question needed. |
|
||||||
|
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` E7M1 gates (NEO-51/101 precedent; no Python on server images). | **Adopted** |
|
||||||
|
| **Cross-ref at load** | Validate objective `itemId` / `recipeId` / `encounterId` against loaded catalogs? | **Yes** — quest loader takes `knownItemIds`, `knownRecipeIds`, `knownEncounterIds` from resolved singletons; load **after** item, recipe, and encounter catalogs (same order as CI `main()`). | **Adopted** |
|
||||||
|
|
||||||
|
## Goal, scope, and out-of-scope
|
||||||
|
|
||||||
|
**Goal:** Disk → host: startup load of `content/quests/*_quests.json` with CI-parity validation; process refuses to listen when catalogs are missing, malformed, or fail prototype E7M1 gates.
|
||||||
|
|
||||||
|
**In scope (from Linear + [E7M1-02](E7M1-prototype-backlog.md#e7m1-02--server-quest-catalog-load-fail-fast)):**
|
||||||
|
|
||||||
|
- Loader + catalog types under `server/NeonSprawl.Server/Game/Quests/`.
|
||||||
|
- Fail-fast on malformed files, duplicate ids (quest / step / objective), allowlist mismatch with CI, broken cross-refs (items, recipes, encounters), acyclic prerequisite graph, chain terminal token gate.
|
||||||
|
- Unit tests (AAA) for loader happy path and failure modes; host boot tests (AAA).
|
||||||
|
- `server/README.md` section for quest catalog.
|
||||||
|
|
||||||
|
**Out of scope (from Linear + backlog):**
|
||||||
|
|
||||||
|
- Injectable registry interface (**NEO-114** / E7M1-03).
|
||||||
|
- HTTP projection, player quest state, runtime state machine (E7M1-04+).
|
||||||
|
- Godot client.
|
||||||
|
|
||||||
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
|
- [x] Host fails startup on invalid quest JSON (mirror CI rules).
|
||||||
|
- [x] Loader tests cover allowlist + cross-ref failures (and host boot per kickoff).
|
||||||
|
|
||||||
|
## Implementation reconciliation (shipped)
|
||||||
|
|
||||||
|
- **Loaders:** `QuestDefinitionCatalogLoader` + `PrototypeE7M1QuestCatalogRules` under `Game/Quests/` with CI-parity gates.
|
||||||
|
- **Catalog:** `QuestDefinitionCatalog` singleton; eager resolve in `Program.cs` after encounter catalogs.
|
||||||
|
- **DI:** `AddQuestDefinitionCatalog` — loads after item, recipe, and encounter catalogs for cross-refs.
|
||||||
|
- **Config:** `ContentPathsOptions` quest path keys; `InMemoryWebApplicationFactory` pins repo `content/quests`.
|
||||||
|
- **Tests:** 20 AAA tests in `QuestDefinitionCatalogLoaderTests` (loader + host boot, incl. row schema violation + chain terminal quantity).
|
||||||
|
- **Docs:** `server/README.md` quest catalog section; alignment register E7.M1 row updated; `E7_M1_QuestStateMachine.md` Status + server-load line.
|
||||||
|
- **Bruno:** `bruno/neon-sprawl-server/quest-catalog/Health after quest catalog load.bru` (boot smoke; no quest HTTP in this story).
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
1. **`Game/Quests/` load-time DTOs** — nested rows mirroring catalog JSON:
|
||||||
|
- `QuestObjectiveDefRow` — `Id`, `Kind`, optional `ItemId`, `Quantity`, `RecipeId`, `EncounterId` (populated per kind).
|
||||||
|
- `QuestStepDefRow` — `Id`, `DisplayName`, `Objectives`.
|
||||||
|
- `QuestDefRow` — `Id`, `DisplayName`, `PrerequisiteQuestIds`, `Steps`.
|
||||||
|
|
||||||
|
2. **`QuestDefinitionCatalog`** — immutable snapshot: directory path, `ById`, `DistinctQuestCount`, `CatalogJsonFileCount`; `TryGetQuest(id, out QuestDefRow?)`.
|
||||||
|
|
||||||
|
3. **`QuestDefinitionCatalogLoader.Load`** — parameters: `questsDirectory`, `questDefSchemaPath`, `questStepDefSchemaPath`, `questObjectiveDefSchemaPath`, `knownItemIds`, `knownRecipeIds`, `knownEncounterIds`, `logger`:
|
||||||
|
- Enumerate `*_quests.json` (top-level only), ordered by path.
|
||||||
|
- Per file: parse JSON; require `schemaVersion == 1` and top-level `quests` array.
|
||||||
|
- Register `quest-objective-def.schema.json`, `quest-step-def.schema.json`, then `quest-def.schema.json` in `SchemaRegistry.Global` before row validation (recipe I/O row pattern).
|
||||||
|
- Per row: JsonSchema.Net validation; track quest `id`; reject duplicate quest `id` across files.
|
||||||
|
- Post-schema: reject duplicate step `id` within a quest; reject duplicate objective `id` within a quest (across all steps — same as CI).
|
||||||
|
- Run **`PrototypeE7M1QuestCatalogRules`** gates (sync comment → `PROTOTYPE_E7M1_*` in `validate_content.py`):
|
||||||
|
- Four-id allowlist gate.
|
||||||
|
- Prerequisite known-id + acyclic DFS gate.
|
||||||
|
- Objective cross-ref gate (`gather_item` / `inventory_has_item` → item ids; `craft_recipe` → recipe ids; `encounter_complete` → encounter ids).
|
||||||
|
- Operator chain terminal step: sole objective `inventory_has_item` **`contract_handoff_token`** ×1.
|
||||||
|
- Throw **`InvalidOperationException`** with aggregated sorted messages prefixed `Quest catalog validation failed:`.
|
||||||
|
- Information log: absolute quests directory, quest count, JSON file count.
|
||||||
|
|
||||||
|
4. **`PrototypeE7M1QuestCatalogRules`** — frozen constants + gate helpers:
|
||||||
|
- `ExpectedQuestIds` — sync → `PROTOTYPE_E7M1_QUEST_IDS`.
|
||||||
|
- `ChainQuestId`, `ChainTerminalItemId` — sync → `PROTOTYPE_E7M1_CHAIN_QUEST_ID`, `PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID`.
|
||||||
|
- Cross-ref sets reuse `PrototypeSlice1ItemCatalogRules.ExpectedItemIds`, `PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds`, `PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds` where applicable (or pass-through from loaded catalogs — loader receives live id sets from DI).
|
||||||
|
|
||||||
|
5. **Path resolution** — `QuestCatalogPathResolution`: `TryDiscoverQuestsDirectory`, `ResolveQuestsDirectory`, `ResolveQuestDefSchemaPath`, `ResolveQuestStepDefSchemaPath`, `ResolveQuestObjectiveDefSchemaPath` (ancestor walk from `AppContext.BaseDirectory` for `content/quests`; schema defaults under sibling `content/schemas/`).
|
||||||
|
|
||||||
|
6. **`ContentPathsOptions`** — add `QuestsDirectory`, `QuestDefSchemaPath`, `QuestStepDefSchemaPath`, `QuestObjectiveDefSchemaPath`.
|
||||||
|
|
||||||
|
7. **`QuestCatalogServiceCollectionExtensions.AddQuestDefinitionCatalog`**:
|
||||||
|
- Factory for `QuestDefinitionCatalog` depends on `ItemDefinitionCatalog`, `RecipeDefinitionCatalog`, `EncounterDefinitionCatalog` (known id sets from `.ById.Keys`).
|
||||||
|
- **No** `IQuestDefinitionRegistry` yet (NEO-114).
|
||||||
|
|
||||||
|
8. **`Program.cs`** — register extension after encounter catalogs; eager-resolve `QuestDefinitionCatalog` before `Run()`.
|
||||||
|
|
||||||
|
9. **`InMemoryWebApplicationFactory`** — pin `Content:QuestsDirectory` to repo path so existing tests keep booting.
|
||||||
|
|
||||||
|
10. **`appsettings.json`** — optional empty `Content:QuestsDirectory` + schema override keys for documentation.
|
||||||
|
|
||||||
|
11. **Tests** — temp-dir fixtures copying repo schemas; valid JSON from [prototype_quests.json](../../content/quests/prototype_quests.json); negative cases for schema violation, duplicate quest/step/objective id, wrong allowlist id, unknown prerequisite, cyclic prerequisites, unknown item/recipe/encounter id, wrong chain terminal objective, `schemaVersion` ≠ 1, missing schema file; host resolve via DI + `/health`; host fail with empty quests dir.
|
||||||
|
|
||||||
|
12. **Docs** — `server/README.md` quest catalog section (config keys, discovery, fail-fast, load order); `documentation_and_implementation_alignment.md` E7.M1 row note server load landed when complete.
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestObjectiveDefRow.cs` | Load-time DTO for a single quest objective row. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestStepDefRow.cs` | Load-time DTO for a quest step (objectives array). |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestDefRow.cs` | Load-time DTO for a single `QuestDef` row. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalog.cs` | In-memory quest catalog + `TryGetQuest`. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestDefinitionCatalogLoader.cs` | Disk I/O, schema validation, duplicate id gates, E7M1 rules, logging. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogPathResolution.cs` | Directory/schema discovery for quests. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/PrototypeE7M1QuestCatalogRules.cs` | Frozen quest ids + prerequisite / cross-ref / chain terminal gates (sync → Python). |
|
||||||
|
| `server/NeonSprawl.Server/Game/Quests/QuestCatalogServiceCollectionExtensions.cs` | `AddQuestDefinitionCatalog` DI registration. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestCatalogTestPaths.cs` | Repo `content/quests` + schema discovery for tests. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | AAA loader + host boot tests for quest catalog. |
|
||||||
|
| `docs/plans/NEO-113-implementation-plan.md` | This plan. |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add quest directory and schema path options under `Content` section. |
|
||||||
|
| `server/NeonSprawl.Server/Program.cs` | Register quest catalog; eager-resolve after build. |
|
||||||
|
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:QuestsDirectory` / schema override keys for documentation. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin quest content path so test host boots with repo catalog. |
|
||||||
|
| `server/README.md` | New quest catalog section (config, discovery, fail-fast, load order). |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E7.M1 row — note NEO-113 server load when complete. |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Test file | What it covers |
|
||||||
|
|-----------|----------------|
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Quests/QuestDefinitionCatalogLoaderTests.cs` | **Unit:** valid prototype quest catalog loads (4 quests); `quests` not array; schema violation; duplicate quest `id`; duplicate step `id` within quest; duplicate objective `id` within quest; wrong/missing E7M1 quest id; unknown prerequisite id; cyclic `prerequisiteQuestIds`; unknown `itemId` / `recipeId` / `encounterId` in objective; wrong chain terminal objective (kind / item / quantity); `schemaVersion` ≠ 1; missing schema file. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI resolve `QuestDefinitionCatalog` (quest `prototype_quest_gather_intro`); `WebApplicationFactory` with empty quests dir fails startup with actionable message. |
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
| Question / risk | Agent recommendation | Status |
|
||||||
|
|-----------------|----------------------|--------|
|
||||||
|
| **Constants drift vs Python** | Comment on `PrototypeE7M1QuestCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_E7M1_*` constants and gate functions. | **adopted** |
|
||||||
|
| **Load order in DI** | Register quest catalog factory after item, recipe, and encounter singletons; pass `.ById.Keys` as known id sets. | **adopted** |
|
||||||
|
| **Test cwd / CI** | `InMemoryWebApplicationFactory` must set `Content:QuestsDirectory` explicitly. | **adopted** |
|
||||||
|
| **Three-schema $ref registration** | Register objective → step → def schemas in `SchemaRegistry.Global` before evaluating quest rows (same as CI `_quest_def_validator`). | **adopted** |
|
||||||
|
| **Objective id uniqueness scope** | Duplicate objective ids rejected **within quest** (all steps), matching NEO-112 CI gate — not per-step. | **adopted** |
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Code review — NEO-113 (E7M1-02)
|
||||||
|
|
||||||
|
**Date:** 2026-05-31
|
||||||
|
**Scope:** Branch `NEO-113-e7m1-server-quest-catalog-load-fail-fast` vs `df24281` (merge-base on `main`) — commits `cb4761b` … `1044547`
|
||||||
|
**Base:** `df24281` (main at branch point, post NEO-112 merge)
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approve with nits**
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
NEO-113 delivers fail-fast server startup loading for `content/quests/*_quests.json`, mirroring the NEO-101 / NEO-112 pattern: row DTOs, `QuestDefinitionCatalogLoader` with JsonSchema.Net validation, duplicate id gates, and `PrototypeE7M1QuestCatalogRules` for CI-parity E7M1 gates (four-id allowlist, acyclic prerequisites, item/recipe/encounter cross-refs against upstream catalogs, operator-chain terminal token). DI registers `QuestDefinitionCatalog` after item, recipe, and encounter catalogs; `Program.cs` eager-resolves before `Run()`. Eighteen AAA loader + host boot tests pass locally; documentation (implementation plan, alignment register, module register note, backlog AC, `server/README.md`) is thorough. No client work (correct). Risk is low — established catalog-load pattern with strong negative coverage; Bruno health smoke is a nice bonus.
|
||||||
|
|
||||||
|
## Documentation checked
|
||||||
|
|
||||||
|
| Path | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `docs/plans/NEO-113-implementation-plan.md` | **Matches** — kickoff decisions adopted; acceptance checklist checked; reconciliation accurate. |
|
||||||
|
| `docs/plans/E7M1-prototype-backlog.md` (E7M1-02) | **Matches** — AC checked; client counterpart none. |
|
||||||
|
| `docs/decomposition/modules/E7_M1_QuestStateMachine.md` | **Matches** — freeze table, CI enforcement + server-load line, Status notes NEO-113 landed. |
|
||||||
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E7.M1 row notes NEO-113 server load; register stays Planned until NEO-114. |
|
||||||
|
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E7.M1 note updated for NEO-113 server load. |
|
||||||
|
| `server/README.md` | **Matches** — quest catalog section (config keys, discovery, fail-fast, load order, NEO-114 forward reference). |
|
||||||
|
| Full-stack epic decomposition | **N/A** — infrastructure-only; no client counterpart until NEO-122/NEO-123. |
|
||||||
|
|
||||||
|
## Blocking issues
|
||||||
|
|
||||||
|
(none)
|
||||||
|
|
||||||
|
## Suggestions
|
||||||
|
|
||||||
|
1. ~~**Refresh `E7_M1_QuestStateMachine.md` Status line** — After merge, note that **E7M1-02 / NEO-113** server load is **landed** and runtime/registry work continues from **NEO-114** (mirror the alignment register wording). Optionally add a one-line “server load (NEO-113)” bullet under **CI enforcement** pointing at `server/README.md#quest-catalog-contentquests-neo-113`.~~ **Done.**
|
||||||
|
|
||||||
|
2. ~~**Add `Load_ShouldThrow_WhenRowViolatesSchema` test** — The implementation plan and NEO-101 precedent (`EncounterDefinitionCatalogLoaderTests.Load_ShouldThrow_WhenRowViolatesSchema`) call for a row-level schema failure case (e.g. empty `displayName` or missing required objective field). Other negative paths are well covered; this is the main gap vs the plan’s test table.~~ **Done.**
|
||||||
|
|
||||||
|
3. ~~**Chain terminal quantity negative test** — Plan lists “wrong chain terminal objective (kind / item / quantity)”; kind and item are covered; adding a quantity ≠ 1 case would close the loop on `TryGetChainTerminalGateError`.~~ **Done.**
|
||||||
|
|
||||||
|
## Nits
|
||||||
|
|
||||||
|
- ~~Nit: Bruno `bruno/neon-sprawl-server/quest-catalog/` is not listed in the implementation plan file table — harmless smoke; consider a one-line mention in plan reconciliation if you want docs to mirror NEO-103/NEO-78 Bruno folders.~~ **Done.** — plan reconciliation lists Bruno boot smoke.
|
||||||
|
|
||||||
|
- Nit: E7M1 four-id gate error strings differ slightly from Python (`'id'` quoted list vs `repr` list) — acceptable; both are actionable.
|
||||||
|
|
||||||
|
- Nit: `QuestDefinitionCatalog` XML doc correctly defers callers to `IQuestDefinitionRegistry` (NEO-114) — good guardrail for the strict NEO-113/114 split.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quest catalog tests (20 — primary signal for this story)
|
||||||
|
cd server
|
||||||
|
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
|
||||||
|
--filter "FullyQualifiedName~QuestDefinitionCatalogLoaderTests"
|
||||||
|
|
||||||
|
# Full server suite (CI; requires Docker for Postgres integration tests)
|
||||||
|
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||||
|
|
||||||
|
# CI parity — Python content gate still passes with repo catalog
|
||||||
|
python3 -m pip install -r scripts/requirements-content.txt
|
||||||
|
python3 scripts/validate_content.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reviewer note:** All 20 `QuestDefinitionCatalogLoaderTests` passed locally after review follow-up. Full server suite: 602 passed; 12 Postgres/docker integration tests skipped/failed in this environment (no Docker daemon) — unrelated to NEO-113.
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||||
|
|
||||||
|
internal static class QuestCatalogTestPaths
|
||||||
|
{
|
||||||
|
internal static string DiscoverRepoQuestsDirectory() =>
|
||||||
|
QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
|
||||||
|
internal static string DiscoverRepoQuestDefSchemaPath() =>
|
||||||
|
QuestCatalogPathResolution.ResolveQuestDefSchemaPath(
|
||||||
|
DiscoverRepoQuestsDirectory(),
|
||||||
|
configuredSchemaPath: null,
|
||||||
|
contentRootPath: string.Empty);
|
||||||
|
|
||||||
|
internal static string DiscoverRepoQuestStepDefSchemaPath() =>
|
||||||
|
QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath(
|
||||||
|
DiscoverRepoQuestsDirectory(),
|
||||||
|
configuredSchemaPath: null,
|
||||||
|
contentRootPath: string.Empty);
|
||||||
|
|
||||||
|
internal static string DiscoverRepoQuestObjectiveDefSchemaPath() =>
|
||||||
|
QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath(
|
||||||
|
DiscoverRepoQuestsDirectory(),
|
||||||
|
configuredSchemaPath: null,
|
||||||
|
contentRootPath: string.Empty);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,626 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
|
using NeonSprawl.Server.Tests;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Quests;
|
||||||
|
|
||||||
|
public class QuestDefinitionCatalogLoaderTests
|
||||||
|
{
|
||||||
|
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
|
||||||
|
|
||||||
|
private static readonly FrozenSet<string> KnownRecipeIds = PrototypeSlice3RecipeCatalogRules.ExpectedRecipeIds;
|
||||||
|
|
||||||
|
private static readonly FrozenSet<string> KnownEncounterIds = PrototypeE5M3EncounterCatalogRules.ExpectedEncounterIds;
|
||||||
|
|
||||||
|
private const string ValidPrototypeCatalogJson =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"quests": [
|
||||||
|
{
|
||||||
|
"id": "prototype_quest_gather_intro",
|
||||||
|
"displayName": "Intro: Salvage Run",
|
||||||
|
"prerequisiteQuestIds": [],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "gather_intro_step_salvage",
|
||||||
|
"displayName": "Gather scrap metal",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "gather_intro_obj_scrap",
|
||||||
|
"kind": "gather_item",
|
||||||
|
"itemId": "scrap_metal_bulk",
|
||||||
|
"quantity": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_quest_refine_intro",
|
||||||
|
"displayName": "Intro: Refine Stock",
|
||||||
|
"prerequisiteQuestIds": ["prototype_quest_gather_intro"],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "refine_intro_step_craft",
|
||||||
|
"displayName": "Refine scrap",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "refine_intro_obj_recipe",
|
||||||
|
"kind": "craft_recipe",
|
||||||
|
"recipeId": "refine_scrap_standard",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_quest_combat_intro",
|
||||||
|
"displayName": "Intro: Clear the Pocket",
|
||||||
|
"prerequisiteQuestIds": [],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "combat_intro_step_encounter",
|
||||||
|
"displayName": "Clear the combat pocket",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "combat_intro_obj_encounter",
|
||||||
|
"kind": "encounter_complete",
|
||||||
|
"encounterId": "prototype_combat_pocket"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prototype_quest_operator_chain",
|
||||||
|
"displayName": "Operator Chain",
|
||||||
|
"prerequisiteQuestIds": [
|
||||||
|
"prototype_quest_gather_intro",
|
||||||
|
"prototype_quest_refine_intro",
|
||||||
|
"prototype_quest_combat_intro"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "chain_step_gather",
|
||||||
|
"displayName": "Bulk salvage",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "chain_obj_gather",
|
||||||
|
"kind": "gather_item",
|
||||||
|
"itemId": "scrap_metal_bulk",
|
||||||
|
"quantity": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chain_step_refine",
|
||||||
|
"displayName": "Refine stock",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "chain_obj_refine",
|
||||||
|
"kind": "craft_recipe",
|
||||||
|
"recipeId": "refine_scrap_standard",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chain_step_stim",
|
||||||
|
"displayName": "Craft field stim",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "chain_obj_stim",
|
||||||
|
"kind": "craft_recipe",
|
||||||
|
"recipeId": "make_field_stim_mk0",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chain_step_token",
|
||||||
|
"displayName": "Hand off contract token",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "chain_obj_token",
|
||||||
|
"kind": "inventory_has_item",
|
||||||
|
"itemId": "contract_handoff_token",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static (string Root, string QuestsDir, string DefSchemaPath, string StepSchemaPath, string ObjectiveSchemaPath) CreateTempContentLayout()
|
||||||
|
{
|
||||||
|
var root = Directory.CreateTempSubdirectory("neon-sprawl-questcat-");
|
||||||
|
var questsDir = Path.Combine(root.FullName, "content", "quests");
|
||||||
|
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||||
|
Directory.CreateDirectory(questsDir);
|
||||||
|
Directory.CreateDirectory(schemaDir);
|
||||||
|
var defSchemaPath = Path.Combine(schemaDir, "quest-def.schema.json");
|
||||||
|
var stepSchemaPath = Path.Combine(schemaDir, "quest-step-def.schema.json");
|
||||||
|
var objectiveSchemaPath = Path.Combine(schemaDir, "quest-objective-def.schema.json");
|
||||||
|
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath(), defSchemaPath, overwrite: true);
|
||||||
|
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath(), stepSchemaPath, overwrite: true);
|
||||||
|
File.Copy(QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath(), objectiveSchemaPath, overwrite: true);
|
||||||
|
return (root.FullName, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCatalog(string questsDir, string catalogJson) =>
|
||||||
|
File.WriteAllText(Path.Combine(questsDir, "prototype_quests.json"), catalogJson, Encoding.UTF8);
|
||||||
|
|
||||||
|
private static JsonObject GetQuestRow(JsonObject catalogRoot, string questId)
|
||||||
|
{
|
||||||
|
var quests = catalogRoot["quests"] as JsonArray
|
||||||
|
?? throw new InvalidOperationException("expected quests array");
|
||||||
|
foreach (var node in quests)
|
||||||
|
{
|
||||||
|
if (node is JsonObject row && row["id"]?.GetValue<string>() == questId)
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"quest id not found: {questId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestDefinitionCatalog LoadCatalog(
|
||||||
|
string questsDir,
|
||||||
|
string defSchemaPath,
|
||||||
|
string stepSchemaPath,
|
||||||
|
string objectiveSchemaPath) =>
|
||||||
|
QuestDefinitionCatalogLoader.Load(
|
||||||
|
questsDir,
|
||||||
|
defSchemaPath,
|
||||||
|
stepSchemaPath,
|
||||||
|
objectiveSchemaPath,
|
||||||
|
KnownItemIds,
|
||||||
|
KnownRecipeIds,
|
||||||
|
KnownEncounterIds,
|
||||||
|
NullLogger.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldSucceed_WhenCatalogMatchesRepoPrototypeFile()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var questsDir = QuestCatalogTestPaths.DiscoverRepoQuestsDirectory();
|
||||||
|
var defSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestDefSchemaPath();
|
||||||
|
var stepSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestStepDefSchemaPath();
|
||||||
|
var objectiveSchemaPath = QuestCatalogTestPaths.DiscoverRepoQuestObjectiveDefSchemaPath();
|
||||||
|
// Act
|
||||||
|
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(4, catalog.DistinctQuestCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
|
||||||
|
// Act
|
||||||
|
var catalog = LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(4, catalog.DistinctQuestCount);
|
||||||
|
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||||
|
Assert.True(catalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
|
||||||
|
Assert.NotNull(gather);
|
||||||
|
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
|
||||||
|
Assert.Empty(gather.PrerequisiteQuestIds);
|
||||||
|
Assert.Single(gather.Steps);
|
||||||
|
Assert.Equal("gather_item", gather.Steps[0].Objectives[0].Kind);
|
||||||
|
Assert.True(catalog.TryGetQuest("prototype_quest_operator_chain", out var chain));
|
||||||
|
Assert.NotNull(chain);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenQuestsIsNotArray()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(questsDir, "bad_quests.json"),
|
||||||
|
"""{"schemaVersion": 1, "quests": "nope"}""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("expected top-level 'quests' array", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(questsDir, "bad_quests.json"),
|
||||||
|
"""{"schemaVersion": 2, "quests": []}""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenRowViolatesSchema()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
const string bad = """
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"quests": [
|
||||||
|
{
|
||||||
|
"id": "prototype_quest_gather_intro",
|
||||||
|
"displayName": "",
|
||||||
|
"prerequisiteQuestIds": [],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "gather_intro_step_salvage",
|
||||||
|
"displayName": "Gather scrap metal",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "gather_intro_obj_scrap",
|
||||||
|
"kind": "gather_item",
|
||||||
|
"itemId": "scrap_metal_bulk",
|
||||||
|
"quantity": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(questsDir, "bad_quests.json"), bad, Encoding.UTF8);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("bad_quests.json", ioe.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("Quest catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenDuplicateQuestIdAcrossFiles()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(questsDir, "extra_quests.json"),
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"quests": [
|
||||||
|
{
|
||||||
|
"id": "prototype_quest_gather_intro",
|
||||||
|
"displayName": "Duplicate",
|
||||||
|
"prerequisiteQuestIds": [],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "dup_step",
|
||||||
|
"displayName": "Dup",
|
||||||
|
"objectives": [
|
||||||
|
{
|
||||||
|
"id": "dup_obj",
|
||||||
|
"kind": "gather_item",
|
||||||
|
"itemId": "scrap_metal_bulk",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
Encoding.UTF8);
|
||||||
|
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("duplicate quest id 'prototype_quest_gather_intro'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenDuplicateStepIdWithinQuest()
|
||||||
|
{
|
||||||
|
// 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 steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
steps.Add(steps[0]!.DeepClone());
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("duplicate step id 'gather_intro_step_salvage'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenDuplicateObjectiveIdWithinQuest()
|
||||||
|
{
|
||||||
|
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var firstStep = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = firstStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
objectives[0]!["id"] = "chain_obj_refine";
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("duplicate objective id 'chain_obj_refine'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenMissingFrozenQuestId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object");
|
||||||
|
var quests = root["quests"] as JsonArray ?? throw new InvalidOperationException("expected quests");
|
||||||
|
quests.RemoveAt(3);
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("prototype E7M1 expects exactly quest ids", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenUnknownPrerequisiteQuestId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object");
|
||||||
|
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
|
||||||
|
refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_missing");
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("prerequisiteQuestIds references unknown quest id", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenCyclicPrerequisiteQuestIds()
|
||||||
|
{
|
||||||
|
// 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["prerequisiteQuestIds"] = new JsonArray("prototype_quest_refine_intro");
|
||||||
|
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
|
||||||
|
refine["prerequisiteQuestIds"] = new JsonArray("prototype_quest_gather_intro");
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("cyclic prerequisiteQuestIds", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenUnknownItemIdInObjective()
|
||||||
|
{
|
||||||
|
// 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 steps = gather["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
|
||||||
|
obj["itemId"] = "unknown_item_id";
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("itemId 'unknown_item_id'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenUnknownRecipeIdInObjective()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object");
|
||||||
|
var refine = GetQuestRow(root, "prototype_quest_refine_intro");
|
||||||
|
var steps = refine["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
|
||||||
|
obj["recipeId"] = "unknown_recipe_id";
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("recipeId 'unknown_recipe_id'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenUnknownEncounterIdInObjective()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||||
|
?? throw new InvalidOperationException("expected object");
|
||||||
|
var combat = GetQuestRow(root, "prototype_quest_combat_intro");
|
||||||
|
var steps = combat["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var step = steps[0] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = step["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
|
||||||
|
obj["encounterId"] = "unknown_encounter_id";
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("encounterId 'unknown_encounter_id'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenChainTerminalObjectiveIsWrongKind()
|
||||||
|
{
|
||||||
|
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
|
||||||
|
obj["kind"] = "craft_recipe";
|
||||||
|
obj["recipeId"] = "refine_scrap_standard";
|
||||||
|
obj["quantity"] = 1;
|
||||||
|
obj.Remove("itemId");
|
||||||
|
obj.Remove("encounterId");
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("terminal objective kind must be 'inventory_has_item'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenChainTerminalItemIsWrong()
|
||||||
|
{
|
||||||
|
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
|
||||||
|
obj["itemId"] = "scrap_metal_bulk";
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("terminal itemId must be 'contract_handoff_token'", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenChainTerminalQuantityIsWrong()
|
||||||
|
{
|
||||||
|
// 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 steps = chain["steps"] as JsonArray ?? throw new InvalidOperationException("expected steps");
|
||||||
|
var lastStep = steps[^1] as JsonObject ?? throw new InvalidOperationException("expected step");
|
||||||
|
var objectives = lastStep["objectives"] as JsonArray ?? throw new InvalidOperationException("expected objectives");
|
||||||
|
var obj = objectives[0] as JsonObject ?? throw new InvalidOperationException("expected objective");
|
||||||
|
obj["quantity"] = 2;
|
||||||
|
WriteCatalog(questsDir, root.ToJsonString());
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("terminal quantity must be 1", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ShouldThrow_WhenMissingSchemaFile()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (_, questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath) = CreateTempContentLayout();
|
||||||
|
WriteCatalog(questsDir, ValidPrototypeCatalogJson);
|
||||||
|
File.Delete(defSchemaPath);
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() => LoadCatalog(questsDir, defSchemaPath, stepSchemaPath, objectiveSchemaPath));
|
||||||
|
// Assert
|
||||||
|
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||||
|
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Host_ShouldResolveQuestCatalogFromDi()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
// Act
|
||||||
|
var response = await client.GetAsync("/health");
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var questCatalog = factory.Services.GetRequiredService<QuestDefinitionCatalog>();
|
||||||
|
Assert.Equal(4, questCatalog.DistinctQuestCount);
|
||||||
|
Assert.True(questCatalog.TryGetQuest("prototype_quest_gather_intro", out var gather));
|
||||||
|
Assert.NotNull(gather);
|
||||||
|
Assert.Equal("Intro: Salvage Run", gather!.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Host_ShouldFailStartup_WhenQuestsDirectoryInvalid()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-quests-" + Guid.NewGuid().ToString("n"));
|
||||||
|
Directory.CreateDirectory(badDir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var ex = Record.Exception(() =>
|
||||||
|
{
|
||||||
|
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||||
|
b.UseSetting("Content:QuestsDirectory", badDir));
|
||||||
|
factory.CreateClient();
|
||||||
|
});
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(ex);
|
||||||
|
Assert.Contains("Quest catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(badDir))
|
||||||
|
Directory.Delete(badDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Gigs;
|
using NeonSprawl.Server.Game.Gigs;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
@ -58,6 +59,9 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
|
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var questsDir = QuestCatalogPathResolution.TryDiscoverQuestsDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/quests from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
|
|
@ -67,6 +71,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
|
||||||
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
|
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
|
||||||
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
builder.UseSetting("Content:EncountersDirectory", encountersDir);
|
||||||
|
builder.UseSetting("Content:QuestsDirectory", questsDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prototype E7M1 quest roster + cross-ref gates (NEO-113), mirrored from
|
||||||
|
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>,
|
||||||
|
/// <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>, <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>,
|
||||||
|
/// and <c>_prototype_e7m1_*</c> gate functions.
|
||||||
|
/// </summary>
|
||||||
|
public static class PrototypeE7M1QuestCatalogRules
|
||||||
|
{
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_QUEST_IDS</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedQuestIds = FrozenSet.ToFrozenSet(
|
||||||
|
[
|
||||||
|
"prototype_quest_gather_intro",
|
||||||
|
"prototype_quest_refine_intro",
|
||||||
|
"prototype_quest_combat_intro",
|
||||||
|
"prototype_quest_operator_chain",
|
||||||
|
],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_QUEST_ID</c>.</summary>
|
||||||
|
public const string ChainQuestId = "prototype_quest_operator_chain";
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID</c>.</summary>
|
||||||
|
public const string ChainTerminalItemId = "contract_handoff_token";
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if the E7M1 quest contract fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetE7M1GateError(IReadOnlyDictionary<string, string> questIdToSourceFile)
|
||||||
|
{
|
||||||
|
var ids = questIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
if (!ids.SetEquals(ExpectedQuestIds))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
"error: prototype E7M1 expects exactly quest ids " +
|
||||||
|
$"[{string.Join(", ", ExpectedQuestIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||||
|
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error when prerequisite ids are unknown or cyclic, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetPrerequisiteGateError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
|
||||||
|
{
|
||||||
|
var known = rowsById.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
foreach (var (qid, row) in rowsById)
|
||||||
|
{
|
||||||
|
foreach (var pid in row.PrerequisiteQuestIds)
|
||||||
|
{
|
||||||
|
if (!known.Contains(pid))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: quest '{qid}': prerequisiteQuestIds references unknown quest id '{pid}'";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var visiting = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
string? Dfs(string node)
|
||||||
|
{
|
||||||
|
if (visiting.Contains(node))
|
||||||
|
return $"error: cyclic prerequisiteQuestIds detected involving quest '{node}'";
|
||||||
|
|
||||||
|
if (visited.Contains(node))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
visiting.Add(node);
|
||||||
|
if (rowsById.TryGetValue(node, out var row))
|
||||||
|
{
|
||||||
|
foreach (var pid in row.PrerequisiteQuestIds)
|
||||||
|
{
|
||||||
|
var err = Dfs(pid);
|
||||||
|
if (err is not null)
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.Remove(node);
|
||||||
|
visited.Add(node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var qid in known)
|
||||||
|
{
|
||||||
|
var err = Dfs(qid);
|
||||||
|
if (err is not null)
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error when objective catalog cross-refs fail, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetCrossRefError(
|
||||||
|
IReadOnlyDictionary<string, QuestDefRow> rowsById,
|
||||||
|
IReadOnlySet<string> knownItemIds,
|
||||||
|
IReadOnlySet<string> knownRecipeIds,
|
||||||
|
IReadOnlySet<string> knownEncounterIds)
|
||||||
|
{
|
||||||
|
foreach (var (qid, row) in rowsById)
|
||||||
|
{
|
||||||
|
for (var si = 0; si < row.Steps.Count; si++)
|
||||||
|
{
|
||||||
|
var step = row.Steps[si];
|
||||||
|
for (var oi = 0; oi < step.Objectives.Count; oi++)
|
||||||
|
{
|
||||||
|
var obj = step.Objectives[oi];
|
||||||
|
if (obj.ItemId is not null &&
|
||||||
|
obj.Kind is "gather_item" or "inventory_has_item" &&
|
||||||
|
!knownItemIds.Contains(obj.ItemId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: quest '{qid}' steps[{si}] objectives[{oi}]: itemId '{obj.ItemId}' " +
|
||||||
|
"is not in the frozen prototype item catalog";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.RecipeId is not null &&
|
||||||
|
obj.Kind == "craft_recipe" &&
|
||||||
|
!knownRecipeIds.Contains(obj.RecipeId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: quest '{qid}' steps[{si}] objectives[{oi}]: recipeId '{obj.RecipeId}' " +
|
||||||
|
"is not in the frozen prototype recipe catalog";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.EncounterId is not null &&
|
||||||
|
obj.Kind == "encounter_complete" &&
|
||||||
|
!knownEncounterIds.Contains(obj.EncounterId))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: quest '{qid}' steps[{si}] objectives[{oi}]: encounterId '{obj.EncounterId}' " +
|
||||||
|
"is not in the frozen prototype encounter catalog";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error when the operator chain terminal step fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetChainTerminalGateError(IReadOnlyDictionary<string, QuestDefRow> rowsById)
|
||||||
|
{
|
||||||
|
if (!rowsById.TryGetValue(ChainQuestId, out var chain))
|
||||||
|
return $"error: missing chain quest '{ChainQuestId}'";
|
||||||
|
|
||||||
|
if (chain.Steps.Count == 0)
|
||||||
|
return $"error: chain quest '{ChainQuestId}' must have at least one step";
|
||||||
|
|
||||||
|
var lastStep = chain.Steps[^1];
|
||||||
|
if (lastStep.Objectives.Count != 1)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: chain quest '{ChainQuestId}' terminal step must have exactly one objective";
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj = lastStep.Objectives[0];
|
||||||
|
if (obj.Kind != "inventory_has_item")
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: chain quest '{ChainQuestId}' terminal objective kind must be 'inventory_has_item'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.ItemId != ChainTerminalItemId)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: chain quest '{ChainQuestId}' terminal itemId must be '{ChainTerminalItemId}', got '{obj.ItemId}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.Quantity != 1)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
$"error: chain quest '{ChainQuestId}' terminal quantity must be 1, got {obj.Quantity}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>Resolves quest catalog paths for local dev, tests, and container layouts (NEO-113).</summary>
|
||||||
|
public static class QuestCatalogPathResolution
|
||||||
|
{
|
||||||
|
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/quests</c> directory.</summary>
|
||||||
|
public static string? TryDiscoverQuestsDirectory(string startDirectory)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(dir.FullName, "content", "quests");
|
||||||
|
if (Directory.Exists(candidate))
|
||||||
|
return Path.GetFullPath(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the quests catalog directory.
|
||||||
|
/// Empty <paramref name="configuredQuestsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveQuestsDirectory(string? configuredQuestsDirectory, string contentRootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredQuestsDirectory))
|
||||||
|
{
|
||||||
|
var discovered = TryDiscoverQuestsDirectory(AppContext.BaseDirectory);
|
||||||
|
if (discovered is not null)
|
||||||
|
return discovered;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Content:QuestsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/quests'). " +
|
||||||
|
"Set Content:QuestsDirectory in configuration or environment (e.g. Content__QuestsDirectory).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = configuredQuestsDirectory.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a single quest row (Draft 2020-12).</summary>
|
||||||
|
public static string ResolveQuestDefSchemaPath(
|
||||||
|
string questsDirectory,
|
||||||
|
string? configuredSchemaPath,
|
||||||
|
string contentRootPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||||
|
{
|
||||||
|
var trimmed = configuredSchemaPath.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-def.schema.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a quest step row.</summary>
|
||||||
|
public static string ResolveQuestStepDefSchemaPath(
|
||||||
|
string questsDirectory,
|
||||||
|
string? configuredSchemaPath,
|
||||||
|
string contentRootPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||||
|
{
|
||||||
|
var trimmed = configuredSchemaPath.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-step-def.schema.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a quest objective row.</summary>
|
||||||
|
public static string ResolveQuestObjectiveDefSchemaPath(
|
||||||
|
string questsDirectory,
|
||||||
|
string? configuredSchemaPath,
|
||||||
|
string contentRootPath)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||||
|
{
|
||||||
|
var trimmed = configuredSchemaPath.Trim();
|
||||||
|
if (Path.IsPathRooted(trimmed))
|
||||||
|
return Path.GetFullPath(trimmed);
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(questsDirectory, "..", "schemas", "quest-objective-def.schema.json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Crafting;
|
||||||
|
using NeonSprawl.Server.Game.Encounters;
|
||||||
|
using NeonSprawl.Server.Game.Items;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>DI registration for the fail-fast quest catalog (NEO-113).</summary>
|
||||||
|
public static class QuestCatalogServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="QuestDefinitionCatalog"/> as a singleton.</summary>
|
||||||
|
public static IServiceCollection AddQuestDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<QuestDefinitionCatalog>(sp =>
|
||||||
|
{
|
||||||
|
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||||
|
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||||
|
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
|
var recipeCatalog = sp.GetRequiredService<RecipeDefinitionCatalog>();
|
||||||
|
var encounterCatalog = sp.GetRequiredService<EncounterDefinitionCatalog>();
|
||||||
|
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("NeonSprawl.Server.Game.Quests.QuestCatalog");
|
||||||
|
|
||||||
|
var questsDir = QuestCatalogPathResolution.ResolveQuestsDirectory(
|
||||||
|
opts.QuestsDirectory,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
var questDefSchemaPath = QuestCatalogPathResolution.ResolveQuestDefSchemaPath(
|
||||||
|
questsDir,
|
||||||
|
opts.QuestDefSchemaPath,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
var questStepDefSchemaPath = QuestCatalogPathResolution.ResolveQuestStepDefSchemaPath(
|
||||||
|
questsDir,
|
||||||
|
opts.QuestStepDefSchemaPath,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
var questObjectiveDefSchemaPath = QuestCatalogPathResolution.ResolveQuestObjectiveDefSchemaPath(
|
||||||
|
questsDir,
|
||||||
|
opts.QuestObjectiveDefSchemaPath,
|
||||||
|
hostEnv.ContentRootPath);
|
||||||
|
|
||||||
|
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var knownRecipeIds = recipeCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var knownEncounterIds = encounterCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
return QuestDefinitionCatalogLoader.Load(
|
||||||
|
questsDir,
|
||||||
|
questDefSchemaPath,
|
||||||
|
questStepDefSchemaPath,
|
||||||
|
questObjectiveDefSchemaPath,
|
||||||
|
knownItemIds,
|
||||||
|
knownRecipeIds,
|
||||||
|
knownEncounterIds,
|
||||||
|
logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>Load-time quest row (NEO-113).</summary>
|
||||||
|
public sealed class QuestDefRow(
|
||||||
|
string id,
|
||||||
|
string displayName,
|
||||||
|
IReadOnlyList<string> prerequisiteQuestIds,
|
||||||
|
IReadOnlyList<QuestStepDefRow> steps)
|
||||||
|
{
|
||||||
|
public string Id { get; } = id;
|
||||||
|
|
||||||
|
public string DisplayName { get; } = displayName;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> PrerequisiteQuestIds { get; } = prerequisiteQuestIds;
|
||||||
|
|
||||||
|
public IReadOnlyList<QuestStepDefRow> Steps { get; } = steps;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>In-memory quest catalog loaded at startup (NEO-113). Game callers should use <see cref="IQuestDefinitionRegistry"/> (NEO-114).</summary>
|
||||||
|
public sealed class QuestDefinitionCatalog(
|
||||||
|
string questsDirectory,
|
||||||
|
IReadOnlyDictionary<string, QuestDefRow> byId,
|
||||||
|
int catalogJsonFileCount)
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path to the directory that was enumerated for <c>*_quests.json</c> catalogs.</summary>
|
||||||
|
public string QuestsDirectory { get; } = questsDirectory;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, QuestDefRow> ById { get; } =
|
||||||
|
new ReadOnlyDictionary<string, QuestDefRow>(new Dictionary<string, QuestDefRow>(byId, StringComparer.Ordinal));
|
||||||
|
|
||||||
|
public int DistinctQuestCount => ById.Count;
|
||||||
|
|
||||||
|
/// <summary>Number of <c>*_quests.json</c> files under <see cref="QuestsDirectory"/>.</summary>
|
||||||
|
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||||
|
|
||||||
|
/// <summary>Resolves a catalog row by stable quest <paramref name="id"/>.</summary>
|
||||||
|
public bool TryGetQuest(string id, out QuestDefRow? row) =>
|
||||||
|
ById.TryGetValue(id, out row);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Json.Schema;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
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>
|
||||||
|
public static class QuestDefinitionCatalogLoader
|
||||||
|
{
|
||||||
|
private static readonly Lock SchemaLoadLock = new();
|
||||||
|
private static JsonSchema? _questObjectiveDefSchema;
|
||||||
|
private static JsonSchema? _questStepDefSchema;
|
||||||
|
private static JsonSchema? _questDefSchema;
|
||||||
|
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||||
|
public static QuestDefinitionCatalog Load(
|
||||||
|
string questsDirectory,
|
||||||
|
string questDefSchemaPath,
|
||||||
|
string questStepDefSchemaPath,
|
||||||
|
string questObjectiveDefSchemaPath,
|
||||||
|
IReadOnlySet<string> knownItemIds,
|
||||||
|
IReadOnlySet<string> knownRecipeIds,
|
||||||
|
IReadOnlySet<string> knownEncounterIds,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
questsDirectory = Path.GetFullPath(questsDirectory);
|
||||||
|
questDefSchemaPath = Path.GetFullPath(questDefSchemaPath);
|
||||||
|
questStepDefSchemaPath = Path.GetFullPath(questStepDefSchemaPath);
|
||||||
|
questObjectiveDefSchemaPath = Path.GetFullPath(questObjectiveDefSchemaPath);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (!File.Exists(questDefSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {questDefSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(questStepDefSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {questStepDefSchemaPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(questObjectiveDefSchemaPath))
|
||||||
|
errors.Add($"error: missing schema file {questObjectiveDefSchemaPath}");
|
||||||
|
|
||||||
|
if (!Directory.Exists(questsDirectory))
|
||||||
|
errors.Add($"error: missing directory {questsDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var jsonFiles = Directory.GetFiles(questsDirectory, "*_quests.json", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(p => p, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
if (jsonFiles.Length == 0)
|
||||||
|
errors.Add($"error: no *_quests.json files under {questsDirectory}");
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
EnsureQuestSchemasLoaded(questObjectiveDefSchemaPath, questStepDefSchemaPath, questDefSchemaPath);
|
||||||
|
var defSchema = _questDefSchema!;
|
||||||
|
|
||||||
|
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||||
|
|
||||||
|
var questIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var rows = new Dictionary<string, QuestDefRow>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var path in jsonFiles)
|
||||||
|
{
|
||||||
|
JsonNode? root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = JsonNode.Parse(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root is not JsonObject rootObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected JSON object at root");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaVersionNode = rootObj["schemaVersion"];
|
||||||
|
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||||
|
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||||
|
schemaVersion != 1)
|
||||||
|
{
|
||||||
|
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||||
|
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var questsNode = rootObj["quests"];
|
||||||
|
if (questsNode is not JsonArray questsArray)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: expected top-level 'quests' array");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < questsArray.Count; i++)
|
||||||
|
{
|
||||||
|
var quest = questsArray[i];
|
||||||
|
if (quest is not JsonObject rowObj)
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path}: quests[{i}] must be an object");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eval = defSchema.Evaluate(rowObj, evalOptions);
|
||||||
|
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||||
|
if (!eval.IsValid)
|
||||||
|
{
|
||||||
|
if (schemaMsgs.Count == 0)
|
||||||
|
schemaMsgs.Add($"error: {path} quests[{i}] (root): schema validation failed");
|
||||||
|
|
||||||
|
errors.AddRange(schemaMsgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var qid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (qid is not null && eval.IsValid)
|
||||||
|
{
|
||||||
|
if (questIdToSourceFile.TryGetValue(qid, out var prevPath))
|
||||||
|
{
|
||||||
|
errors.Add($"error: duplicate quest id '{qid}' in {prevPath} and {path}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stepParseErrors = TryParseSteps(rowObj, path, i, out var steps, out _, out _);
|
||||||
|
errors.AddRange(stepParseErrors);
|
||||||
|
|
||||||
|
questIdToSourceFile[qid] = path;
|
||||||
|
rows[qid] = ParseRow(rowObj, steps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
|
||||||
|
var e7m1 = PrototypeE7M1QuestCatalogRules.TryGetE7M1GateError(questIdToSourceFile);
|
||||||
|
if (e7m1 is not null)
|
||||||
|
{
|
||||||
|
errors.Add(e7m1);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var prereq = PrototypeE7M1QuestCatalogRules.TryGetPrerequisiteGateError(rows);
|
||||||
|
if (prereq is not null)
|
||||||
|
{
|
||||||
|
errors.Add(prereq);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var crossRef = PrototypeE7M1QuestCatalogRules.TryGetCrossRefError(
|
||||||
|
rows,
|
||||||
|
knownItemIds,
|
||||||
|
knownRecipeIds,
|
||||||
|
knownEncounterIds);
|
||||||
|
if (crossRef is not null)
|
||||||
|
{
|
||||||
|
errors.Add(crossRef);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
var chainTerminal = PrototypeE7M1QuestCatalogRules.TryGetChainTerminalGateError(rows);
|
||||||
|
if (chainTerminal is not null)
|
||||||
|
{
|
||||||
|
errors.Add(chainTerminal);
|
||||||
|
ThrowIfAny(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logger.IsEnabled(LogLevel.Information))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Loaded quest catalog from {QuestsDirectory}: {QuestCount} quest(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||||
|
questsDirectory,
|
||||||
|
rows.Count,
|
||||||
|
jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuestDefinitionCatalog(questsDirectory, rows, jsonFiles.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> TryParseSteps(
|
||||||
|
JsonObject rowObj,
|
||||||
|
string path,
|
||||||
|
int questIndex,
|
||||||
|
out IReadOnlyList<QuestStepDefRow> steps,
|
||||||
|
out HashSet<string> stepIdsSeen,
|
||||||
|
out HashSet<string> objectiveIdsSeen)
|
||||||
|
{
|
||||||
|
var errors = new List<string>();
|
||||||
|
stepIdsSeen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
objectiveIdsSeen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var parsedSteps = new List<QuestStepDefRow>();
|
||||||
|
|
||||||
|
var stepsNode = rowObj["steps"] as JsonArray;
|
||||||
|
if (stepsNode is null)
|
||||||
|
{
|
||||||
|
steps = parsedSteps;
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var si = 0; si < stepsNode.Count; si++)
|
||||||
|
{
|
||||||
|
if (stepsNode[si] is not JsonObject stepObj)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var sid = (stepObj["id"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (sid is not null)
|
||||||
|
{
|
||||||
|
if (stepIdsSeen.Contains(sid))
|
||||||
|
{
|
||||||
|
errors.Add($"error: {path} quests[{questIndex}] steps[{si}]: duplicate step id '{sid}'");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stepIdsSeen.Add(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectivesNode = stepObj["objectives"] as JsonArray;
|
||||||
|
if (objectivesNode is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (var oi = 0; oi < objectivesNode.Count; oi++)
|
||||||
|
{
|
||||||
|
if (objectivesNode[oi] is not JsonObject objObj)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var oid = (objObj["id"] as JsonValue)?.GetValue<string>();
|
||||||
|
if (oid is not null)
|
||||||
|
{
|
||||||
|
if (objectiveIdsSeen.Contains(oid))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
$"error: {path} quests[{questIndex}] steps[{si}] objectives[{oi}]: " +
|
||||||
|
$"duplicate objective id '{oid}' within quest");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
objectiveIdsSeen.Add(oid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var si = 0; si < stepsNode.Count; si++)
|
||||||
|
{
|
||||||
|
if (stepsNode[si] is JsonObject stepObj)
|
||||||
|
parsedSteps.Add(ParseStep(stepObj));
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = parsedSteps;
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestDefRow ParseRow(JsonObject rowObj, IReadOnlyList<QuestStepDefRow> steps)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestStepDefRow ParseStep(JsonObject stepObj)
|
||||||
|
{
|
||||||
|
var id = (stepObj["id"] as JsonValue)!.GetValue<string>();
|
||||||
|
var displayName = (stepObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
|
var objectivesNode = stepObj["objectives"] as JsonArray ?? [];
|
||||||
|
var objectives = new List<QuestObjectiveDefRow>(objectivesNode.Count);
|
||||||
|
foreach (var node in objectivesNode)
|
||||||
|
{
|
||||||
|
if (node is JsonObject objObj)
|
||||||
|
objectives.Add(ParseObjective(objObj));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuestStepDefRow(id, displayName, objectives);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuestObjectiveDefRow ParseObjective(JsonObject objObj)
|
||||||
|
{
|
||||||
|
var id = (objObj["id"] as JsonValue)!.GetValue<string>();
|
||||||
|
var kind = (objObj["kind"] as JsonValue)!.GetValue<string>();
|
||||||
|
string? itemId = null;
|
||||||
|
int? quantity = null;
|
||||||
|
string? recipeId = null;
|
||||||
|
string? encounterId = null;
|
||||||
|
|
||||||
|
if (objObj["itemId"] is JsonValue itemValue && itemValue.TryGetValue<string>(out var item))
|
||||||
|
itemId = item;
|
||||||
|
|
||||||
|
if (objObj["quantity"] is JsonValue qtyValue && qtyValue.TryGetValue<int>(out var qty))
|
||||||
|
quantity = qty;
|
||||||
|
|
||||||
|
if (objObj["recipeId"] is JsonValue recipeValue && recipeValue.TryGetValue<string>(out var recipe))
|
||||||
|
recipeId = recipe;
|
||||||
|
|
||||||
|
if (objObj["encounterId"] is JsonValue encounterValue && encounterValue.TryGetValue<string>(out var encounter))
|
||||||
|
encounterId = encounter;
|
||||||
|
|
||||||
|
return new QuestObjectiveDefRow(id, kind, itemId, quantity, recipeId, encounterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ParseStringArray(JsonArray? array)
|
||||||
|
{
|
||||||
|
if (array is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var values = new List<string>(array.Count);
|
||||||
|
foreach (var node in array)
|
||||||
|
{
|
||||||
|
if (node is JsonValue value && value.TryGetValue<string>(out var s))
|
||||||
|
values.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||||
|
{
|
||||||
|
var sink = new List<string>();
|
||||||
|
AppendSchemaMessages(eval, filePath, index, sink);
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||||
|
{
|
||||||
|
if (r.HasDetails)
|
||||||
|
{
|
||||||
|
foreach (var d in r.Details!)
|
||||||
|
AppendSchemaMessages(d, filePath, index, sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!r.HasErrors)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var kv in r.Errors!)
|
||||||
|
{
|
||||||
|
var loc = r.InstanceLocation?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||||
|
loc = "(root)";
|
||||||
|
|
||||||
|
sink.Add($"error: {filePath} quests[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registers objective → step → def in <see cref="SchemaRegistry.Global"/> once per process (NEO-113).</summary>
|
||||||
|
private static void EnsureQuestSchemasLoaded(
|
||||||
|
string questObjectiveDefSchemaPath,
|
||||||
|
string questStepDefSchemaPath,
|
||||||
|
string questDefSchemaPath)
|
||||||
|
{
|
||||||
|
lock (SchemaLoadLock)
|
||||||
|
{
|
||||||
|
if (_questDefSchema is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var objectiveSchema = JsonSchema.FromText(File.ReadAllText(questObjectiveDefSchemaPath));
|
||||||
|
SchemaRegistry.Global.Register(objectiveSchema);
|
||||||
|
_questObjectiveDefSchema = objectiveSchema;
|
||||||
|
|
||||||
|
var stepSchema = JsonSchema.FromText(File.ReadAllText(questStepDefSchemaPath));
|
||||||
|
SchemaRegistry.Global.Register(stepSchema);
|
||||||
|
_questStepDefSchema = stepSchema;
|
||||||
|
|
||||||
|
var defSchema = JsonSchema.FromText(File.ReadAllText(questDefSchemaPath));
|
||||||
|
SchemaRegistry.Global.Register(defSchema);
|
||||||
|
_questDefSchema = defSchema;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowIfAny(List<string> errors)
|
||||||
|
{
|
||||||
|
if (errors.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Quest catalog validation failed:");
|
||||||
|
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||||
|
sb.AppendLine(e);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>Load-time quest objective row (NEO-113).</summary>
|
||||||
|
public sealed class QuestObjectiveDefRow(
|
||||||
|
string id,
|
||||||
|
string kind,
|
||||||
|
string? itemId,
|
||||||
|
int? quantity,
|
||||||
|
string? recipeId,
|
||||||
|
string? encounterId)
|
||||||
|
{
|
||||||
|
public string Id { get; } = id;
|
||||||
|
|
||||||
|
public string Kind { get; } = kind;
|
||||||
|
|
||||||
|
public string? ItemId { get; } = itemId;
|
||||||
|
|
||||||
|
public int? Quantity { get; } = quantity;
|
||||||
|
|
||||||
|
public string? RecipeId { get; } = recipeId;
|
||||||
|
|
||||||
|
public string? EncounterId { get; } = encounterId;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
/// <summary>Load-time quest step row (NEO-113).</summary>
|
||||||
|
public sealed class QuestStepDefRow(
|
||||||
|
string id,
|
||||||
|
string displayName,
|
||||||
|
IReadOnlyList<QuestObjectiveDefRow> objectives)
|
||||||
|
{
|
||||||
|
public string Id { get; } = id;
|
||||||
|
|
||||||
|
public string DisplayName { get; } = displayName;
|
||||||
|
|
||||||
|
public IReadOnlyList<QuestObjectiveDefRow> Objectives { get; } = objectives;
|
||||||
|
}
|
||||||
|
|
@ -130,4 +130,28 @@ public sealed class ContentPathsOptions
|
||||||
/// When unset, resolved as <c>{parent of encounters directory}/schemas/encounter-def.schema.json</c>.
|
/// When unset, resolved as <c>{parent of encounters directory}/schemas/encounter-def.schema.json</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? EncounterDefSchemaPath { get; set; }
|
public string? EncounterDefSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||||
|
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/quests</c> directory.
|
||||||
|
/// </summary>
|
||||||
|
public string? QuestsDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>quest-def.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-def.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? QuestDefSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>quest-step-def.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-step-def.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? QuestStepDefSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>quest-objective-def.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of quests directory}/schemas/quest-objective-def.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? QuestObjectiveDefSchemaPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Npc;
|
using NeonSprawl.Server.Game.Npc;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Quests;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using NeonSprawl.Server.Game.Targeting;
|
using NeonSprawl.Server.Game.Targeting;
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration);
|
||||||
|
builder.Services.AddQuestDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddThreatStateStore();
|
builder.Services.AddThreatStateStore();
|
||||||
builder.Services.AddNpcRuntimeStateStore();
|
builder.Services.AddNpcRuntimeStateStore();
|
||||||
builder.Services.AddPlayerCombatHealthStore();
|
builder.Services.AddPlayerCombatHealthStore();
|
||||||
|
|
@ -41,6 +43,7 @@ _ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
|
||||||
|
_ = app.Services.GetRequiredService<QuestDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,11 @@
|
||||||
"RewardTableDefSchemaPath": "",
|
"RewardTableDefSchemaPath": "",
|
||||||
"RewardGrantRowSchemaPath": "",
|
"RewardGrantRowSchemaPath": "",
|
||||||
"EncountersDirectory": "",
|
"EncountersDirectory": "",
|
||||||
"EncounterDefSchemaPath": ""
|
"EncounterDefSchemaPath": "",
|
||||||
|
"QuestsDirectory": "",
|
||||||
|
"QuestDefSchemaPath": "",
|
||||||
|
"QuestStepDefSchemaPath": "",
|
||||||
|
"QuestObjectiveDefSchemaPath": ""
|
||||||
},
|
},
|
||||||
"Game": {
|
"Game": {
|
||||||
"DevPlayerId": "dev-local-1",
|
"DevPlayerId": "dev-local-1",
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,21 @@ On startup the host loads every **`*_encounters.json`** under the encounters dir
|
||||||
|
|
||||||
On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** 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 ability/hotbar routes.
|
On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** 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 ability/hotbar routes.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
| Config | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| **`Content:QuestsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/quests`**. |
|
||||||
|
| **`Content:QuestDefSchemaPath`** | Optional override for **`quest-def.schema.json`**. When unset, **`{parent of quests directory}/schemas/quest-def.schema.json`**. |
|
||||||
|
| **`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.
|
||||||
|
|
||||||
|
On success, **Information** logs include the resolved quests directory path, distinct quest count, and catalog file count. **`IQuestDefinitionRegistry`** (NEO-114) will be the lookup surface for game code; until then the **`QuestDefinitionCatalog`** singleton is registered for fail-fast startup only—do not inject it in new game code.
|
||||||
|
|
||||||
## Encounter definitions (NEO-103)
|
## Encounter definitions (NEO-103)
|
||||||
|
|
||||||
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
**`GET /game/world/encounter-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`encounters`**) backed by **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, nested **`completionCriteria`** (`kind`), **`requiredNpcInstanceIds`**, and nested **`rewardTable`** (`id`, `displayName`, **`fixedGrants`** with `itemId` + `quantity`). Plan: [NEO-103 implementation plan](../../docs/plans/NEO-103-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/encounter-definitions/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue