NEO-113: add kickoff implementation plan for quest catalog load.

pull/152/head
VinPropane 2026-05-31 21:31:14 -04:00
parent df24281417
commit cb4761b0b4
1 changed files with 135 additions and 0 deletions

View File

@ -0,0 +1,135 @@
# 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
- [ ] Host fails startup on invalid quest JSON (mirror CI rules).
- [ ] Loader tests cover allowlist + cross-ref failures (and host boot per kickoff).
## 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** |