diff --git a/docs/plans/NEO-66-implementation-plan.md b/docs/plans/NEO-66-implementation-plan.md new file mode 100644 index 0000000..5e18834 --- /dev/null +++ b/docs/plans/NEO-66-implementation-plan.md @@ -0,0 +1,121 @@ +# NEO-66 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-66 | +| **Title** | E3.M2: Server recipe catalog load (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-66/e3m2-server-recipe-catalog-load-fail-fast | +| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-02** | +| **Branch** | `NEO-66-e3m2-server-recipe-catalog-load-fail-fast` | +| **Precursor** | [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) — frozen eight-recipe catalog + CI gates (**Done** on `main`) | +| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket ([NEO-67](https://linear.app/neon-sprawl/issue/NEO-67)) | +| **Blocks** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) — recipe definition registry + DI | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **NEO-66 vs NEO-67 (DI scope)** | Ship `IRecipeDefinitionRegistry` on this story, or only load + `RecipeDefinitionCatalog`? | **Strict split** — NEO-66: loader + `RecipeDefinitionCatalog` + eager fail-fast boot; [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) adds injectable `IRecipeDefinitionRegistry` (E3M2-03). Rationale: E3M2 backlog separates E3M2-02/03; mirrors NEO-51/NEO-52 and NEO-58/NEO-59. | **User:** `RecipeDefinitionCatalog` singleton only (kickoff). | +| **Cross-check dependencies** | How to validate I/O `itemId` and `requiredSkillId` at startup? | **Pass ids from `ItemDefinitionCatalog` and `SkillDefinitionCatalog`** in the DI factory (items + skills load first; no second disk read). Rationale: NEO-58 / NEO-46 load-time precedent. | **Adopted** — NEO-58 precedent; no separate question. | +| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + Slice 3 recipe gate). | **Adopted** — NEO-51 / NEO-34 precedent. | +| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` / `ResourceNodeCatalogLoaderTests`. | **Adopted** — NEO-51 precedent. | +| **Lookup API on NEO-66** | How does “catalog resolves `RecipeDef` and I/O rows by recipe `id`” land without NEO-67? | **`RecipeDefinitionCatalog.ById`** (read-only map) + `TryGetRecipe(string id, out RecipeDefRow row)`; inputs/outputs embedded on `RecipeDefRow`. | **Adopted** | + +## Goal, scope, and out-of-scope + +**Goal:** On host startup, load all `content/recipes/*_recipes.json` catalogs (validated in CI per [NEO-65](NEO-65-implementation-plan.md)), build an in-memory **`RecipeDefinitionCatalog`**, cross-check every I/O **`itemId`** against the already-loaded item catalog and every **`requiredSkillId`** against the skill catalog, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype Slice 3 gate. + +**In scope (from Linear + [E3M2-02](E3M2-prototype-backlog.md#e3m2-02--server-recipe-catalog-load-fail-fast)):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Crafting/`. +- Configurable recipes directory and schema paths (`ContentPathsOptions` extension). +- CI-parity validation: `schemaVersion`, row schemas (`recipe-def` + `$ref` to `recipe-io-row`), duplicate `id`, cross-check item + skill ids, Slice 3 eight-id + `recipeKind` + `refine` gate. +- DI registration of **`RecipeDefinitionCatalog`** singleton (factory depends on `ItemDefinitionCatalog` + `SkillDefinitionCatalog`); eager resolve in `Program.cs` after item + skill catalogs. +- Unit + host startup tests (AAA). + +**Out of scope (from Linear):** + +- Per-player craft state, HTTP, inventory mutation. +- **`IRecipeDefinitionRegistry`** / `RecipeDefinitionRegistry` (**NEO-67**, E3M2-03). + +## Acceptance criteria checklist + +- [ ] Server refuses boot when recipe catalog invalid (missing dir, no `*_recipes.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, unknown `itemId` / `requiredSkillId`, Slice 3 gate failure). +- [ ] `RecipeDefinitionCatalog` resolves prototype rows by recipe `id` (e.g. `refine_scrap_standard`, `make_field_stim_mk0`) including embedded input/output rows. +- [ ] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. +- [ ] Success log includes **recipe count**, **catalog directory**, and **file count** (Information). + +## Technical approach + +1. **`Game/Crafting/` row DTOs** — `RecipeIoRow` (`ItemId`, `Quantity`); `RecipeDefRow` (`Id`, `DisplayName`, `RecipeKind`, `RequiredSkillId`, `Inputs`, `Outputs`, optional `RequiredSkillLevel`, `StationTag`). + +2. **`RecipeDefinitionCatalogLoader.Load(recipesDirectory, recipeDefSchemaPath, recipeIoRowSchemaPath, knownItemIds, knownSkillIds, logger)`** — single-pass load mirroring `validate_content.py` `_validate_recipe_catalogs` + `_prototype_slice3_recipe_gate`: + - Enumerate `*_recipes.json` (top-level only), ordered by path. + - Build **JsonSchema.Net** evaluator with both schemas registered by `$id` (same `$ref` URLs as Python: `https://neon-sprawl.local/schemas/recipe-def.json` + `recipe-io-row.json`). + - Per file: parse JSON; require `schemaVersion == 1` and top-level `recipes` array. + - Per row: validate against composite recipe-def schema; on clean rows track `id`, `recipeKind`, `requiredSkillId`; build `RecipeDefRow` with parsed I/O arrays; reject duplicate `id` across files. + - Post-schema cross-checks per row: every I/O `itemId` ∈ `knownItemIds`; `requiredSkillId` ∈ `knownSkillIds`. + - Run **`PrototypeSlice3RecipeCatalogRules.TryGetSlice3GateError`** (mirror `PROTOTYPE_SLICE3_RECIPE_IDS`, `PROTOTYPE_SLICE3_REFINE_SKILL_ID`, `process` + `make` coverage in `validate_content.py`). + - Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Recipe catalog validation failed:`. + +3. **`RecipeDefinitionCatalog`** — immutable snapshot: `RecipesDirectory`, `ById`, `DistinctRecipeCount`, `CatalogJsonFileCount`; `TryGetRecipe(id, out RecipeDefRow?)` for lookups (I/O rows on the row DTO). + +4. **`RecipeCatalogPathResolution`** — `TryDiscoverRecipesDirectory`, `ResolveRecipesDirectory`, `ResolveRecipeDefSchemaPath`, `ResolveRecipeIoRowSchemaPath` (parent `content/schemas/` layout when unset). + +5. **`PrototypeSlice3RecipeCatalogRules`** — frozen eight recipe ids + refine skill id + `recipeKind` coverage gate; sync comments to `scripts/validate_content.py` `PROTOTYPE_SLICE3_RECIPE_IDS` / `PROTOTYPE_SLICE3_REFINE_SKILL_ID` / `_prototype_slice3_recipe_gate`. + +6. **`RecipeCatalogServiceCollectionExtensions.AddRecipeDefinitionCatalog`** — bind `ContentPathsOptions`; register **`RecipeDefinitionCatalog`** singleton factory that resolves paths, gets `ItemDefinitionCatalog.ById.Keys` and `SkillDefinitionCatalog.ById.Keys` as hash sets, calls loader. + +7. **`Program.cs`** — `AddRecipeDefinitionCatalog(configuration)` after `AddItemDefinitionCatalog` and `AddSkillDefinitionCatalog`; after `Build()`, `GetRequiredService()` alongside existing catalog eager resolves. + +8. **Tests** — temp-dir fixtures copying repo `recipe-def.schema.json` + `recipe-io-row.schema.json`; valid eight-recipe JSON matching [prototype_recipes.json](../../content/recipes/prototype_recipes.json); stub item + skill id sets for cross-check; negative cases (duplicate id, unknown item/skill, wrong Slice 3 roster, missing `process`/`make`, bad `requiredSkillId`); `InMemoryWebApplicationFactory` / `WebApplicationFactory` host tests mirroring items. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Crafting/RecipeIoRow.cs` | Load-time recipe input/output row DTO. | +| `server/NeonSprawl.Server/Game/Crafting/RecipeDefRow.cs` | Load-time `RecipeDef` row DTO with embedded I/O lists. | +| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs` | In-memory catalog + `TryGetRecipe`. | +| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs` | Disk I/O, schema validation ($ref), Slice 3 gate, item + skill cross-check. | +| `server/NeonSprawl.Server/Game/Crafting/RecipeCatalogPathResolution.cs` | Directory/schema discovery and config resolution. | +| `server/NeonSprawl.Server/Game/Crafting/PrototypeSlice3RecipeCatalogRules.cs` | Frozen eight ids + refine skill + recipeKind gate (sync comment to Python). | +| `server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs` | `AddRecipeDefinitionCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeCatalogTestPaths.cs` | Repo `content/recipes` + schema discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests. | +| `docs/plans/NEO-66-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `RecipesDirectory`, `RecipeDefSchemaPath`, `RecipeIoRowSchemaPath` under `Content` section. | +| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `RecipeDefinitionCatalog` at startup (after item + skill catalogs). | +| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:RecipesDirectory` / schema path keys for override documentation (if other catalogs document keys here). | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:RecipesDirectory` to discovered repo path so existing tests keep booting. | +| `server/README.md` | New **Recipe catalog (`content/recipes`, NEO-66)** section (config keys, discovery, fail-fast, Slice 3 parity with CI). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-66 server load when implementation completes. | +| `docs/plans/E3M2-prototype-backlog.md` | E3M2-02 checkboxes when implementation completes. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs` | **Unit:** valid eight-recipe prototype catalog loads; `recipes` not array; schema violation (incl. bad I/O row); duplicate `id` across files; wrong/missing Slice 3 ids; missing `process` or `make`; `requiredSkillId` ≠ `refine`; unknown `itemId` in inputs/outputs; unknown `requiredSkillId`; `schemaVersion` ≠ 1; missing dir/schema. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `RecipeDefinitionCatalog` count 8; `WebApplicationFactory` with empty recipes dir fails startup with actionable message. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Schema `$ref` in C#** | Register both recipe schemas by `$id` in JsonSchema.Net before evaluating rows (mirror Python `_recipe_def_validator`). | **adopted** | +| **Constants drift vs Python** | Comment on `PrototypeSlice3RecipeCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_SLICE3_RECIPE_IDS` / `_prototype_slice3_recipe_gate`. | **adopted** | +| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:RecipesDirectory` explicitly (do not rely on discovery alone in CI). | **adopted** | +| **Registry consumers** | Craft engine / HTTP use `IRecipeDefinitionRegistry` in NEO-67+; this story only exposes `RecipeDefinitionCatalog` for boot + direct test lookup. | **adopted** | + +## Decisions (kickoff) + +- **`RecipeDefinitionCatalog` singleton only** — defer `IRecipeDefinitionRegistry` to NEO-67 (user confirmed kickoff). +- **Item + skill cross-check** uses `ItemDefinitionCatalog` and `SkillDefinitionCatalog` keys passed into loader at DI registration time. +- **In-process validation** + **loader and host boot tests** per NEO-51 / NEO-58 precedent.