Merge pull request #100 from ViPro-Technologies/NEO-66-e3m2-server-recipe-catalog-load-fail-fast

NEO-66: E3.M2 server recipe catalog load (fail-fast)
pull/101/head
VinPropane 2026-05-24 16:53:21 -04:00 committed by GitHub
commit 565906ff8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1336 additions and 5 deletions

View File

@ -0,0 +1,25 @@
meta {
name: GET health (recipe catalog boot NEO-66)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-66 loads content/recipes/*_recipes.json at startup (fail-fast). No recipe 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");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: recipe-catalog
}

View File

@ -13,7 +13,7 @@
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP), same persistence and **`allowedXpSourceKinds`** rules as **`POST …/skill-progression`**. **No** `CraftRequest` / **`CraftResult`** route yet; when this module adds authoritative craft/refine success, call that helper once on success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)).
**E3M2-01 (NEO-65):** Frozen eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65 plan](../../plans/NEO-65-implementation-plan.md)). **No** server loader yet — E3M2-02 (NEO-66) owns fail-fast boot load.
**E3M2-01 (NEO-65):** Frozen eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65 plan](../../plans/NEO-65-implementation-plan.md)). **E3M2-02 (NEO-66):** fail-fast server load under `server/NeonSprawl.Server/Game/Crafting/``RecipeDefinitionCatalogLoader`, `RecipeDefinitionCatalog`, cross-check vs item + skill catalogs, Slice 3 gate; [NEO-66 plan](../../plans/NEO-66-implementation-plan.md); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66).
## Purpose

View File

@ -55,7 +55,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01****E3M1-08** complete. **Slice 5 client (planned):** gather feedback HUD [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/` |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **Slice 5 client (planned):** inventory HUD [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
| E3.M2 | In Progress | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** — craft/refine success should award **`refine`** XP via shared NEO-38 grant path; no craft route yet. **Slice 3 backlog in Linear:** [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-02****E3M2-07** open. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](https://linear.app/neon-sprawl/issue/NEO-66) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
| E3.M2 | In Progress | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** — craft/refine success should award **`refine`** XP via shared NEO-38 grant path; no craft route yet. **Slice 3 backlog in Linear:** [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) → [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71); [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-03****E3M2-07** open. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) |
---

View File

@ -105,8 +105,10 @@ Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../d
**Acceptance criteria**
- [ ] Server refuses boot when recipe catalog invalid (mirror E3.M1 / E3.M3 catalog behavior).
- [ ] Catalog resolves `RecipeDef` and I/O rows by recipe `id`.
- [x] Server refuses boot when recipe catalog invalid (mirror E3.M1 / E3.M3 catalog behavior).
- [x] Catalog resolves `RecipeDef` and I/O rows by recipe `id`.
**Landed ([NEO-66](https://linear.app/neon-sprawl/issue/NEO-66)):** `server/NeonSprawl.Server/Game/Crafting/``RecipeDefinitionCatalogLoader`, `RecipeDefinitionCatalog`, `PrototypeSlice3RecipeCatalogRules`; plan [NEO-66-implementation-plan.md](NEO-66-implementation-plan.md).
---

View File

@ -0,0 +1,123 @@
# 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.

View File

@ -0,0 +1,63 @@
# Code review — NEO-66 server recipe catalog load (fail-fast)
**Date:** 2026-05-24
**Scope:** Branch `NEO-66-e3m2-server-recipe-catalog-load-fail-fast` · commits `ccf4cf8``f209636` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-66 delivers **E3M2-02**: fail-fast startup load of `content/recipes/*_recipes.json` under `server/NeonSprawl.Server/Game/Crafting/`, mirroring the NEO-51 item-catalog pattern. The loader validates Draft 2020-12 schemas (with `$ref` registration for I/O rows), cross-checks `itemId` / `requiredSkillId` against already-loaded item and skill catalogs, enforces the frozen Slice 3 eight-recipe gate (`PrototypeSlice3RecipeCatalogRules`), registers `RecipeDefinitionCatalog` in DI, and eagerly resolves it in `Program.cs`. Fourteen AAA loader/host tests pass; docs (plan, E3M2 backlog, E3.M2 snapshot, alignment register, `server/README.md`) are updated. Bruno adds a health smoke request for post-boot verification. No client or HTTP recipe API — correctly deferred to NEO-67+. Risk is low: infrastructure-only, CI already gates content; main residual risk is test-factory path pinning consistency across parallel host boots.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-66-implementation-plan.md`](../plans/NEO-66-implementation-plan.md) | **Matches** — loader, catalog, path resolution, Slice 3 rules, DI, `Program.cs`, tests, doc touches; acceptance checklist complete; `IRecipeDefinitionRegistry` correctly out of scope. |
| [`docs/plans/E3M2-prototype-backlog.md`](../plans/E3M2-prototype-backlog.md) | **Matches** — E3M2-02 checkboxes + landed note. |
| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — implementation snapshot references NEO-66 load; freeze table and CI rules unchanged. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row updated with NEO-66 landed note; next backlog item NEO-67. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — table **Status** still **Planned** for E3.M2 (same pattern as postNEO-65); optional bump when Slice 3 server spine completes. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — server startup catalog only; no client mutation. |
| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — C# gate mirrors `_validate_recipe_catalogs` + `_prototype_slice3_recipe_gate`; sync comments on `PrototypeSlice3RecipeCatalogRules`. |
| [`server/README.md`](../../server/README.md) | **Matches** — Recipe catalog section (config keys, discovery, fail-fast, Slice 3 parity). |
| Manual QA | **N/A** — plan documents skip rationale: automated loader/host tests + Bruno health smoke (same pattern as NEO-51); no player-visible HTTP. |
Register/tracking: alignment table E3.M2 **In Progress** with NEO-66 note is correct.
## Blocking issues
None.
## Suggestions
1. ~~**Pin `Content:RecipesDirectory` in other test factories** — `InMemoryWebApplicationFactory` pins recipes (per plan). Also pin in `PostgresWebApplicationFactory`, `MissionRewardDeniedRegistryWebApplicationFactory`, and `RefineActivityDeniedRegistryWebApplicationFactory` (and any future host factories) so CI never depends on ancestor discovery alone when layout differs. `SalvageActivityDeniedRegistryWebApplicationFactory` already pins items/resource-nodes; add recipes there too for symmetry.~~ **Done.** All five host factories now pin `Content:RecipesDirectory`.
2. ~~**Loader test gaps vs plan test table** — Add AAA cases for: empty recipes directory (no `*_recipes.json`), invalid JSON file, unknown `itemId` in **outputs** (inputs covered). Low risk given CI + existing coverage, but closes the plans enumerated failure modes.~~ **Done.** Added `Load_ShouldThrow_WhenNoRecipeCatalogFiles`, `Load_ShouldThrow_WhenJsonIsInvalid`, `Load_ShouldThrow_WhenUnknownItemIdInOutputs`.
3. ~~**Plan Tests section — manual QA skip note** — Add one line stating manual QA is skipped because acceptance is fully covered by unit/host tests + Bruno health (per planning-implementation-docs).~~ **Done.** See [NEO-66 plan](../plans/NEO-66-implementation-plan.md) **Tests** section.
## Nits
- Nit: `SchemaRegistry.Global.Register` in `RecipeDefinitionCatalogLoader` mutates process-global state; acceptable for startup load, but parallel test runs share the registry (same as any future multi-catalog `$ref` loaders).
- Nit: `ValidPrototypeCatalogJson` duplicates `content/recipes/prototype_recipes.json` inline — fine for isolated temp-dir fixtures; optional future refactor to read/copy repo file.
- Nit: Full `dotnet test` suite showed occasional parallel host-boot flakes (unrelated tests pass in isolation); not introduced by recipe tests specifically, but extra eager catalog resolve adds startup work — watch CI if flakes increase.
## Verification
```bash
cd /home/don/neon-sprawl/server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~RecipeDefinitionCatalogLoaderTests"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual (optional):
- Start server from repo root; confirm Information log: recipe count **8**, catalog directory, file count **1**.
- Bruno: `bruno/neon-sprawl-server/recipe-catalog/Health after recipe catalog load.bru` → 200 + `service: NeonSprawl.Server`.
- Negative: set `Content__RecipesDirectory` to an empty temp dir → startup fails with `Recipe catalog validation failed:`.

View File

@ -0,0 +1,23 @@
using NeonSprawl.Server.Game.Crafting;
namespace NeonSprawl.Server.Tests.Game.Crafting;
internal static class RecipeCatalogTestPaths
{
internal static string DiscoverRepoRecipesDirectory() =>
RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoRecipeDefSchemaPath() =>
RecipeCatalogPathResolution.ResolveRecipeDefSchemaPath(
DiscoverRepoRecipesDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
internal static string DiscoverRepoRecipeIoRowSchemaPath() =>
RecipeCatalogPathResolution.ResolveRecipeIoRowSchemaPath(
DiscoverRepoRecipesDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,511 @@
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.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;
public class RecipeDefinitionCatalogLoaderTests
{
private static readonly FrozenSet<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
private static readonly FrozenSet<string> KnownSkillIds = FrozenSet.ToFrozenSet(
["salvage", "refine", "intrusion"],
StringComparer.Ordinal);
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"recipes": [
{
"id": "refine_scrap_standard",
"displayName": "Refine Scrap (Standard)",
"recipeKind": "process",
"requiredSkillId": "refine",
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
},
{
"id": "refine_scrap_efficient",
"displayName": "Refine Scrap (Efficient)",
"recipeKind": "process",
"requiredSkillId": "refine",
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
},
{
"id": "make_field_stim_mk0",
"displayName": "Make Field Stim Mk0",
"recipeKind": "make",
"requiredSkillId": "refine",
"inputs": [
{ "itemId": "refined_plate_stock", "quantity": 2 },
{ "itemId": "scrap_metal_bulk", "quantity": 1 }
],
"outputs": [{ "itemId": "field_stim_mk0", "quantity": 1 }]
},
{
"id": "make_field_stim_batch",
"displayName": "Make Field Stim (Batch)",
"recipeKind": "make",
"requiredSkillId": "refine",
"inputs": [
{ "itemId": "refined_plate_stock", "quantity": 5 },
{ "itemId": "scrap_metal_bulk", "quantity": 3 }
],
"outputs": [{ "itemId": "field_stim_mk0", "quantity": 3 }]
},
{
"id": "make_prototype_armor",
"displayName": "Make Prototype Armor Shell",
"recipeKind": "make",
"requiredSkillId": "refine",
"inputs": [
{ "itemId": "refined_plate_stock", "quantity": 8 },
{ "itemId": "scrap_metal_bulk", "quantity": 5 }
],
"outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
},
{
"id": "make_armor_quick",
"displayName": "Make Armor (Quick)",
"recipeKind": "make",
"requiredSkillId": "refine",
"inputs": [
{ "itemId": "refined_plate_stock", "quantity": 4 },
{ "itemId": "scrap_metal_bulk", "quantity": 3 }
],
"outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
},
{
"id": "make_survey_drone_kit",
"displayName": "Make Survey Drone Kit",
"recipeKind": "make",
"requiredSkillId": "refine",
"inputs": [
{ "itemId": "refined_plate_stock", "quantity": 3 },
{ "itemId": "scrap_metal_bulk", "quantity": 2 }
],
"outputs": [{ "itemId": "survey_drone_kit", "quantity": 1 }]
},
{
"id": "make_contract_token",
"displayName": "Make Contract Handoff Token",
"recipeKind": "make",
"requiredSkillId": "refine",
"inputs": [
{ "itemId": "refined_plate_stock", "quantity": 2 },
{ "itemId": "scrap_metal_bulk", "quantity": 1 }
],
"outputs": [{ "itemId": "contract_handoff_token", "quantity": 1 }]
}
]
}
""";
private static (string Root, string RecipesDir, string DefSchemaPath, string IoSchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-recipecat-");
var recipesDir = Path.Combine(root.FullName, "content", "recipes");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(recipesDir);
Directory.CreateDirectory(schemaDir);
var defSchemaPath = Path.Combine(schemaDir, "recipe-def.schema.json");
var ioSchemaPath = Path.Combine(schemaDir, "recipe-io-row.schema.json");
File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath(), defSchemaPath, overwrite: true);
File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath(), ioSchemaPath, overwrite: true);
return (root.FullName, recipesDir, defSchemaPath, ioSchemaPath);
}
private static void WriteCatalog(string recipesDir, string catalogJson) =>
File.WriteAllText(Path.Combine(recipesDir, "prototype_recipes.json"), catalogJson, Encoding.UTF8);
private static JsonObject GetRecipeRow(JsonObject catalogRoot, string recipeId)
{
var recipes = catalogRoot["recipes"] as JsonArray
?? throw new InvalidOperationException("expected recipes array");
foreach (var node in recipes)
{
if (node is JsonObject row && row["id"]?.GetValue<string>() == recipeId)
return row;
}
throw new InvalidOperationException($"recipe id not found: {recipeId}");
}
private static RecipeDefinitionCatalog LoadCatalog(
string recipesDir,
string defSchemaPath,
string ioSchemaPath) =>
RecipeDefinitionCatalogLoader.Load(
recipesDir,
defSchemaPath,
ioSchemaPath,
KnownItemIds,
KnownSkillIds,
NullLogger.Instance);
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
WriteCatalog(recipesDir, ValidPrototypeCatalogJson);
// Act
var catalog = LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath);
// Assert
Assert.Equal(8, catalog.DistinctRecipeCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetRecipe("refine_scrap_standard", out var row));
Assert.NotNull(row);
Assert.Equal("process", row!.RecipeKind);
Assert.Single(row.Inputs);
Assert.Equal("scrap_metal_bulk", row.Inputs[0].ItemId);
Assert.Equal(5, row.Inputs[0].Quantity);
}
[Fact]
public void Load_ShouldThrow_WhenRecipesIsNotArray()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(recipesDir, "bad_recipes.json"),
"""{"schemaVersion": 1, "recipes": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'recipes' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(recipesDir, "bad_recipes.json"),
"""{"schemaVersion": 2, "recipes": []}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
const string bad = """
{
"schemaVersion": 1,
"recipes": [
{
"id": "refine_scrap_standard",
"displayName": "",
"recipeKind": "process",
"requiredSkillId": "refine",
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
}
]
}
""";
File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Recipe catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
const string singleRecipe = """
{
"schemaVersion": 1,
"recipes": [
{
"id": "refine_scrap_standard",
"displayName": "Refine Scrap (Standard)",
"recipeKind": "process",
"requiredSkillId": "refine",
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
}
]
}
""";
File.WriteAllText(Path.Combine(recipesDir, "a_recipes.json"), singleRecipe, Encoding.UTF8);
File.WriteAllText(Path.Combine(recipesDir, "b_recipes.json"), singleRecipe, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate recipe id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("refine_scrap_standard", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice3IdsIncomplete()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
const string twoOnly = """
{
"schemaVersion": 1,
"recipes": [
{
"id": "refine_scrap_standard",
"displayName": "Refine Scrap (Standard)",
"recipeKind": "process",
"requiredSkillId": "refine",
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
},
{
"id": "refine_scrap_efficient",
"displayName": "Refine Scrap (Efficient)",
"recipeKind": "process",
"requiredSkillId": "refine",
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
}
]
}
""";
File.WriteAllText(Path.Combine(recipesDir, "partial_recipes.json"), twoOnly, Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 3 expects exactly recipe ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenMissingMakeRecipeKind()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
var recipes = root["recipes"] as JsonArray ?? throw new InvalidOperationException("expected recipes array");
foreach (var node in recipes)
{
if (node is JsonObject row && row["recipeKind"]?.GetValue<string>() == "make")
row["recipeKind"] = "process";
}
WriteCatalog(recipesDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requires at least one process and one make recipe", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRequiredSkillIdIsNotRefine()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetRecipeRow(root, "make_field_stim_mk0")["requiredSkillId"] = "salvage";
WriteCatalog(recipesDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("make_field_stim_mk0", ioe.Message, StringComparison.Ordinal);
Assert.Contains("requiredSkillId must be 'refine'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownItemIdInInputs()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
var row = GetRecipeRow(root, "refine_scrap_standard");
var inputs = row["inputs"] as JsonArray ?? throw new InvalidOperationException("expected inputs array");
inputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 5}""");
WriteCatalog(recipesDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownItemIdInOutputs()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
var row = GetRecipeRow(root, "refine_scrap_standard");
var outputs = row["outputs"] as JsonArray ?? throw new InvalidOperationException("expected outputs array");
outputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 1}""");
WriteCatalog(recipesDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal);
Assert.Contains("outputs[0]", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenNoRecipeCatalogFiles()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("no *_recipes.json files", ioe.Message, StringComparison.Ordinal);
Assert.Contains(recipesDir, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenJsonIsInvalid()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), "{ not json", Encoding.UTF8);
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownRequiredSkillId()
{
// Arrange
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetRecipeRow(root, "refine_scrap_standard")["requiredSkillId"] = "unknown_skill_xyz";
WriteCatalog(recipesDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("unknown_skill_xyz", ioe.Message, StringComparison.Ordinal);
Assert.Contains("is not a known SkillDef id", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRecipesDirectoryMissing()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-recipes-" + Guid.NewGuid().ToString("n"));
var defSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath();
var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath();
// Act
var ex = Record.Exception(() =>
RecipeDefinitionCatalogLoader.Load(
missingDir,
defSchemaPath,
ioSchemaPath,
KnownItemIds,
KnownSkillIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaFileMissing()
{
// Arrange
var (_, recipesDir, _, _) = CreateTempContentLayout();
var missingSchema = Path.Combine(recipesDir, "missing-recipe-def.schema.json");
var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath();
// Act
var ex = Record.Exception(() =>
RecipeDefinitionCatalogLoader.Load(
recipesDir,
missingSchema,
ioSchemaPath,
KnownItemIds,
KnownSkillIds,
NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = factory.Services.GetRequiredService<RecipeDefinitionCatalog>();
Assert.Equal(8, catalog.DistinctRecipeCount);
Assert.True(catalog.TryGetRecipe("make_field_stim_mk0", out var stim));
Assert.Equal("make", stim!.RecipeKind);
Assert.Equal(2, stim.Inputs.Count);
}
[Fact]
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-recipes-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:RecipesDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Recipe catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -37,10 +38,14 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
builder.UseSetting("Content:RecipesDirectory", recipesDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
@ -35,10 +36,14 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
builder.UseSetting("Content:RecipesDirectory", recipesDir);
builder.ConfigureAppConfiguration((_, config) =>
{

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
@ -29,8 +30,12 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:RecipesDirectory", recipesDir);
builder.ConfigureTestServices(services =>
{

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
@ -29,8 +30,12 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:RecipesDirectory", recipesDir);
builder.ConfigureTestServices(services =>
{

View File

@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
@ -38,10 +39,14 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
builder.UseSetting("Content:RecipesDirectory", recipesDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>

View File

@ -0,0 +1,62 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>
/// Prototype Slice 3 roster gate (NEO-65), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_SLICE3_RECIPE_IDS</c> / <c>_prototype_slice3_recipe_gate</c>.
/// </summary>
public static class PrototypeSlice3RecipeCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE3_RECIPE_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedRecipeIds = FrozenSet.ToFrozenSet(
[
"refine_scrap_standard",
"refine_scrap_efficient",
"make_field_stim_mk0",
"make_field_stim_batch",
"make_prototype_armor",
"make_armor_quick",
"make_survey_drone_kit",
"make_contract_token",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE3_REFINE_SKILL_ID</c>.</summary>
public const string ExpectedRefineSkillId = "refine";
/// <summary>Returns a human-readable error if the Slice 3 recipe contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetSlice3GateError(
IReadOnlyDictionary<string, string> recipeIdToSourceFile,
IReadOnlyDictionary<string, string> idToRecipeKind,
IReadOnlyDictionary<string, string> idToRequiredSkillId)
{
var ids = recipeIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedRecipeIds))
{
return
"error: prototype Slice 3 expects exactly recipe ids " +
$"[{string.Join(", ", ExpectedRecipeIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}
var kinds = idToRecipeKind.Values.ToHashSet(StringComparer.Ordinal);
if (!kinds.Contains("process") || !kinds.Contains("make"))
{
return
"error: prototype Slice 3 requires at least one process and one make recipe; " +
$"recipeKind values seen: [{string.Join(", ", kinds.Order(StringComparer.Ordinal).Select(k => "'" + k + "'"))}]";
}
foreach (var (rid, skillId) in idToRequiredSkillId)
{
if (skillId != ExpectedRefineSkillId)
{
return
$"error: '{rid}' requiredSkillId must be '{ExpectedRefineSkillId}', got '{skillId}'";
}
}
return null;
}
}

View File

@ -0,0 +1,78 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>Resolves recipe catalog paths for local dev, tests, and container layouts (NEO-66).</summary>
public static class RecipeCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/recipes</c> directory.</summary>
public static string? TryDiscoverRecipesDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "recipes");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the recipes catalog directory.
/// Empty <paramref name="configuredRecipesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveRecipesDirectory(string? configuredRecipesDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredRecipesDirectory))
{
var discovered = TryDiscoverRecipesDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:RecipesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/recipes'). " +
"Set Content:RecipesDirectory in configuration or environment (e.g. Content__RecipesDirectory).");
}
var trimmed = configuredRecipesDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single recipe row (Draft 2020-12).</summary>
public static string ResolveRecipeDefSchemaPath(
string recipesDirectory,
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(recipesDirectory, "..", "schemas", "recipe-def.schema.json"));
}
/// <summary>Resolves JSON Schema path for a recipe I/O row (Draft 2020-12).</summary>
public static string ResolveRecipeIoRowSchemaPath(
string recipesDirectory,
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(recipesDirectory, "..", "schemas", "recipe-io-row.schema.json"));
}
}

View File

@ -0,0 +1,53 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>DI registration for the fail-fast recipe catalog (NEO-66).</summary>
public static class RecipeCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="RecipeDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddRecipeDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<RecipeDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Crafting.RecipeCatalog");
var recipesDir = RecipeCatalogPathResolution.ResolveRecipesDirectory(
opts.RecipesDirectory,
hostEnv.ContentRootPath);
var recipeDefSchemaPath = RecipeCatalogPathResolution.ResolveRecipeDefSchemaPath(
recipesDir,
opts.RecipeDefSchemaPath,
hostEnv.ContentRootPath);
var recipeIoRowSchemaPath = RecipeCatalogPathResolution.ResolveRecipeIoRowSchemaPath(
recipesDir,
opts.RecipeIoRowSchemaPath,
hostEnv.ContentRootPath);
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
var knownSkillIds = skillCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
return RecipeDefinitionCatalogLoader.Load(
recipesDir,
recipeDefSchemaPath,
recipeIoRowSchemaPath,
knownItemIds,
knownSkillIds,
logger);
});
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>One validated <c>RecipeDef</c> row from <c>content/recipes/*_recipes.json</c> (NEO-66).</summary>
public sealed record RecipeDefRow(
string Id,
string DisplayName,
string RecipeKind,
string RequiredSkillId,
IReadOnlyList<RecipeIoRow> Inputs,
IReadOnlyList<RecipeIoRow> Outputs,
int? RequiredSkillLevel,
string? StationTag);

View File

@ -0,0 +1,24 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>In-memory recipe catalog loaded at startup (NEO-66). NEO-67 adds injectable recipe definition registry for game callers.</summary>
public sealed class RecipeDefinitionCatalog(
string recipesDirectory,
IReadOnlyDictionary<string, RecipeDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_recipes.json</c> catalogs.</summary>
public string RecipesDirectory { get; } = recipesDirectory;
public IReadOnlyDictionary<string, RecipeDefRow> ById { get; } = new ReadOnlyDictionary<string, RecipeDefRow>(new Dictionary<string, RecipeDefRow>(byId, StringComparer.Ordinal));
public int DistinctRecipeCount => ById.Count;
/// <summary>Number of <c>*_recipes.json</c> files under <see cref="RecipesDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable recipe <paramref name="id"/> (includes embedded I/O rows).</summary>
public bool TryGetRecipe(string id, out RecipeDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -0,0 +1,285 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>Loads and validates <c>content/recipes/*_recipes.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-66).</summary>
public static class RecipeDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static RecipeDefinitionCatalog Load(
string recipesDirectory,
string recipeDefSchemaPath,
string recipeIoRowSchemaPath,
IReadOnlySet<string> knownItemIds,
IReadOnlySet<string> knownSkillIds,
ILogger logger)
{
recipesDirectory = Path.GetFullPath(recipesDirectory);
recipeDefSchemaPath = Path.GetFullPath(recipeDefSchemaPath);
recipeIoRowSchemaPath = Path.GetFullPath(recipeIoRowSchemaPath);
var errors = new List<string>();
if (!File.Exists(recipeDefSchemaPath))
errors.Add($"error: missing schema file {recipeDefSchemaPath}");
if (!File.Exists(recipeIoRowSchemaPath))
errors.Add($"error: missing schema file {recipeIoRowSchemaPath}");
if (!Directory.Exists(recipesDirectory))
errors.Add($"error: missing directory {recipesDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(recipesDirectory, "*_recipes.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_recipes.json files under {recipesDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var ioSchemaText = File.ReadAllText(recipeIoRowSchemaPath);
var defSchemaText = File.ReadAllText(recipeDefSchemaPath);
var ioSchema = JsonSchema.FromText(ioSchemaText);
SchemaRegistry.Global.Register(ioSchema);
var defSchema = JsonSchema.FromText(defSchemaText);
SchemaRegistry.Global.Register(defSchema);
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var recipeIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var idToRecipeKind = new Dictionary<string, string>(StringComparer.Ordinal);
var idToRequiredSkillId = new Dictionary<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, RecipeDefRow>(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 recipesNode = rootObj["recipes"];
if (recipesNode is not JsonArray recipesArray)
{
errors.Add($"error: {path}: expected top-level 'recipes' array");
continue;
}
for (var i = 0; i < recipesArray.Count; i++)
{
var recipe = recipesArray[i];
if (recipe is not JsonObject rowObj)
{
errors.Add($"error: {path}: recipes[{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} recipes[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var rowSchemaErrors = schemaMsgs.Count;
var rid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (rid is not null && rowSchemaErrors == 0)
{
if (recipeIdToSourceFile.TryGetValue(rid, out var prevPath))
{
errors.Add($"error: duplicate recipe id '{rid}' in {prevPath} and {path}");
continue;
}
recipeIdToSourceFile[rid] = path;
var recipeKind = (rowObj["recipeKind"] as JsonValue)?.GetValue<string>();
if (recipeKind is not null)
idToRecipeKind[rid] = recipeKind;
var requiredSkillId = (rowObj["requiredSkillId"] as JsonValue)?.GetValue<string>();
if (requiredSkillId is not null)
{
idToRequiredSkillId[rid] = requiredSkillId;
if (!knownSkillIds.Contains(requiredSkillId))
{
errors.Add(
$"error: {path} recipes[{i}]: requiredSkillId '{requiredSkillId}' " +
$"is not a known SkillDef id (known: [{string.Join(", ", knownSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}])");
}
}
CrossCheckIoRows(rowObj, path, i, "inputs", knownItemIds, errors);
CrossCheckIoRows(rowObj, path, i, "outputs", knownItemIds, errors);
rows[rid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var slice3 = PrototypeSlice3RecipeCatalogRules.TryGetSlice3GateError(
recipeIdToSourceFile,
idToRecipeKind,
idToRequiredSkillId);
if (slice3 is not null)
{
errors.Add(slice3);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded recipe catalog from {RecipesDirectory}: {RecipeCount} recipe(s) across {CatalogFileCount} JSON catalog file(s).",
recipesDirectory,
rows.Count,
jsonFiles.Length);
}
return new RecipeDefinitionCatalog(recipesDirectory, rows, jsonFiles.Length);
}
private static void CrossCheckIoRows(
JsonObject rowObj,
string path,
int recipeIndex,
string ioKey,
IReadOnlySet<string> knownItemIds,
List<string> errors)
{
if (rowObj[ioKey] is not JsonArray ioArray)
return;
for (var j = 0; j < ioArray.Count; j++)
{
if (ioArray[j] is not JsonObject ioRow)
continue;
var itemId = (ioRow["itemId"] as JsonValue)?.GetValue<string>();
if (itemId is not null && !knownItemIds.Contains(itemId))
{
errors.Add(
$"error: {path} recipes[{recipeIndex}].{ioKey}[{j}]: itemId '{itemId}' is not in item catalogs");
}
}
}
private static RecipeDefRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var recipeKind = (rowObj["recipeKind"] as JsonValue)!.GetValue<string>();
var requiredSkillId = (rowObj["requiredSkillId"] as JsonValue)!.GetValue<string>();
var inputs = ParseIoRows(rowObj["inputs"] as JsonArray);
var outputs = ParseIoRows(rowObj["outputs"] as JsonArray);
int? requiredSkillLevel = null;
if (rowObj["requiredSkillLevel"] is JsonValue levelValue &&
levelValue.TryGetValue<int>(out var level))
{
requiredSkillLevel = level;
}
string? stationTag = null;
if (rowObj["stationTag"] is JsonValue stationTagValue)
stationTag = stationTagValue.GetValue<string>();
return new RecipeDefRow(id, displayName, recipeKind, requiredSkillId, inputs, outputs, requiredSkillLevel, stationTag);
}
private static IReadOnlyList<RecipeIoRow> ParseIoRows(JsonArray? array)
{
if (array is null)
return [];
var rows = new List<RecipeIoRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject ioObj)
continue;
var itemId = (ioObj["itemId"] as JsonValue)!.GetValue<string>();
var quantity = (ioObj["quantity"] as JsonValue)!.GetValue<int>();
rows.Add(new RecipeIoRow(itemId, quantity));
}
return rows;
}
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} recipes[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Recipe catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>One validated input or output row on a <see cref="RecipeDefRow"/> (NEO-66).</summary>
public sealed record RecipeIoRow(string ItemId, int Quantity);

View File

@ -58,4 +58,22 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of resource-nodes directory}/schemas/resource-yield-row.schema.json</c>.
/// </summary>
public string? ResourceYieldRowSchemaPath { 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/recipes</c> directory.
/// </summary>
public string? RecipesDirectory { get; set; }
/// <summary>
/// Optional override for <c>recipe-def.schema.json</c>.
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-def.schema.json</c>.
/// </summary>
public string? RecipeDefSchemaPath { get; set; }
/// <summary>
/// Optional override for <c>recipe-io-row.schema.json</c>.
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-io-row.schema.json</c>.
/// </summary>
public string? RecipeIoRowSchemaPath { get; set; }
}

View File

@ -1,4 +1,5 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Crafting;
using NeonSprawl.Server.Game.Gathering;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Items;
@ -17,12 +18,14 @@ builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockSt
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
builder.Services.AddResourceNodeCatalog(builder.Configuration);
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -10,7 +10,10 @@
"SkillsDirectory": "",
"SkillDefSchemaPath": "",
"ItemsDirectory": "",
"ItemDefSchemaPath": ""
"ItemDefSchemaPath": "",
"RecipesDirectory": "",
"RecipeDefSchemaPath": "",
"RecipeIoRowSchemaPath": ""
},
"Game": {
"DevPlayerId": "dev-local-1",

View File

@ -66,6 +66,20 @@ On success, **Information** logs include the resolved resource-nodes directory p
**`IResourceNodeDefinitionRegistry`** (NEO-59) is the preferred lookup surface for gather engine, depletion, interact, and HTTP callers — resolve node defs and yield rows by **`nodeDefId`**, enumerate defs in id order. The catalog singleton remains for fail-fast startup only; do not inject **`ResourceNodeCatalog`** in new game code.
## Recipe catalog (`content/recipes`, NEO-66)
On startup the host loads every **`*_recipes.json`** under the recipes directory, validates each row against **`content/schemas/recipe-def.schema.json`** (with **`recipe-io-row.schema.json`** for I/O `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate recipe `id`** values across files, cross-checks every input/output **`itemId`** against the **item catalog** and every **`requiredSkillId`** against the **skill catalog** (both loaded first), and enforces the **prototype Slice 3** roster 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:RecipesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/recipes`**. |
| **`Content:RecipeDefSchemaPath`** | Optional override for **`recipe-def.schema.json`**. When unset, **`{parent of recipes directory}/schemas/recipe-def.schema.json`**. |
| **`Content:RecipeIoRowSchemaPath`** | Optional override for **`recipe-io-row.schema.json`**. When unset, **`{parent of recipes directory}/schemas/recipe-io-row.schema.json`**. |
**Docker / CI:** include **`content/recipes`**, **`content/items`**, **`content/skills`**, and the two recipe schemas in the mounted **`content/`** tree; set **`Content__RecipesDirectory`** when layout differs.
On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. NEO-67 adds **`IRecipeDefinitionRegistry`** for game callers; until then, **`RecipeDefinitionCatalog`** is the startup-loaded snapshot.
## Resource node definitions (NEO-60)
**`GET /game/world/resource-node-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`nodes`**) backed by **`IResourceNodeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`gatherLens`**, **`maxGathers`**, and nested **`yield`** (`itemId`, `quantity`). Plan: [NEO-60 implementation plan](../../docs/plans/NEO-60-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-60.md`](../../docs/manual-qa/NEO-60.md); Bruno: `bruno/neon-sprawl-server/resource-node-definitions/`.