# 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

- [x] 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).
- [x] `RecipeDefinitionCatalog` resolves prototype rows by recipe `id` (e.g. `refine_scrap_standard`, `make_field_stim_mk0`) including embedded input/output rows.
- [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory.
- [x] 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<RecipeDefinitionCatalog>()` 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; no `*_recipes.json` files; invalid JSON. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `RecipeDefinitionCatalog` count 8; `WebApplicationFactory` with empty recipes dir fails startup with actionable message. |

**Manual QA:** Skipped — no player-visible HTTP; acceptance is fully covered by AAA loader/host tests above plus Bruno `recipe-catalog/Health after recipe catalog load.bru` (same pattern as NEO-51).

## 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.
