diff --git a/docs/plans/NEO-113-implementation-plan.md b/docs/plans/NEO-113-implementation-plan.md new file mode 100644 index 0000000..1ca7791 --- /dev/null +++ b/docs/plans/NEO-113-implementation-plan.md @@ -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** |