diff --git a/bruno/neon-sprawl-server/recipe-catalog/Health after recipe catalog load.bru b/bruno/neon-sprawl-server/recipe-catalog/Health after recipe catalog load.bru new file mode 100644 index 0000000..130619e --- /dev/null +++ b/bruno/neon-sprawl-server/recipe-catalog/Health after recipe catalog load.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/recipe-catalog/folder.bru b/bruno/neon-sprawl-server/recipe-catalog/folder.bru new file mode 100644 index 0000000..377d5df --- /dev/null +++ b/bruno/neon-sprawl-server/recipe-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: recipe-catalog +} diff --git a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md index 668acf0..27463f9 100644 --- a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md +++ b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md @@ -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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index ce80e62..ea6acaa 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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-37–NEO-41, NEO-42–NEO-44 | | E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-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) | --- diff --git a/docs/plans/E3M2-prototype-backlog.md b/docs/plans/E3M2-prototype-backlog.md index 9c8ff47..4de6720 100644 --- a/docs/plans/E3M2-prototype-backlog.md +++ b/docs/plans/E3M2-prototype-backlog.md @@ -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). --- diff --git a/docs/plans/NEO-66-implementation-plan.md b/docs/plans/NEO-66-implementation-plan.md new file mode 100644 index 0000000..c0b696a --- /dev/null +++ b/docs/plans/NEO-66-implementation-plan.md @@ -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()` 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. diff --git a/docs/reviews/2026-05-24-NEO-66.md b/docs/reviews/2026-05-24-NEO-66.md new file mode 100644 index 0000000..8164c86 --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-66.md @@ -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 post–NEO-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 plan’s 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:`. diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeCatalogTestPaths.cs new file mode 100644 index 0000000..c760374 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeCatalogTestPaths.cs @@ -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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..04ee8c9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs @@ -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 KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds; + + private static readonly FrozenSet 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() == 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(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(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(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(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(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() == "make") + row["recipeKind"] = "process"; + } + + WriteCatalog(recipesDir, root.ToJsonString()); + // Act + var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath)); + // Assert + var ioe = Assert.IsType(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(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(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(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(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(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(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(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(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(); + 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().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); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs index fabbdb8..f9efdbe 100644 --- a/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Interaction/SalvageActivityDeniedRegistryWebApplicationFactory.cs @@ -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 => diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 4c13ca3..0337336 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -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 { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs index 5055913..1e29349 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/MissionRewardDeniedRegistryWebApplicationFactory.cs @@ -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 => { diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs index bd18798..ea04cc8 100644 --- a/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/Skills/RefineActivityDeniedRegistryWebApplicationFactory.cs @@ -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 => { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 8713388..0afbe68 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -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 diff --git a/server/NeonSprawl.Server/Game/Crafting/PrototypeSlice3RecipeCatalogRules.cs b/server/NeonSprawl.Server/Game/Crafting/PrototypeSlice3RecipeCatalogRules.cs new file mode 100644 index 0000000..e0dab48 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/PrototypeSlice3RecipeCatalogRules.cs @@ -0,0 +1,62 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Crafting; + +/// +/// Prototype Slice 3 roster gate (NEO-65), mirrored from scripts/validate_content.py +/// PROTOTYPE_SLICE3_RECIPE_IDS / _prototype_slice3_recipe_gate. +/// +public static class PrototypeSlice3RecipeCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE3_RECIPE_IDS. + public static readonly FrozenSet 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); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE3_REFINE_SKILL_ID. + public const string ExpectedRefineSkillId = "refine"; + + /// Returns a human-readable error if the Slice 3 recipe contract fails, otherwise . + public static string? TryGetSlice3GateError( + IReadOnlyDictionary recipeIdToSourceFile, + IReadOnlyDictionary idToRecipeKind, + IReadOnlyDictionary 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeCatalogPathResolution.cs new file mode 100644 index 0000000..fa11caf --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeCatalogPathResolution.cs @@ -0,0 +1,78 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// Resolves recipe catalog paths for local dev, tests, and container layouts (NEO-66). +public static class RecipeCatalogPathResolution +{ + /// Walks and parents for an existing content/recipes directory. + 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; + } + + /// + /// Resolves the recipes catalog directory. + /// Empty triggers discovery from . + /// + 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)); + } + + /// Resolves JSON Schema path for a single recipe row (Draft 2020-12). + 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")); + } + + /// Resolves JSON Schema path for a recipe I/O row (Draft 2020-12). + 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")); + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..f7c1097 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs @@ -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; + +/// DI registration for the fail-fast recipe catalog (NEO-66). +public static class RecipeCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddRecipeDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var itemCatalog = sp.GetRequiredService(); + var skillCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .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; + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefRow.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefRow.cs new file mode 100644 index 0000000..560aed6 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefRow.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// One validated RecipeDef row from content/recipes/*_recipes.json (NEO-66). +public sealed record RecipeDefRow( + string Id, + string DisplayName, + string RecipeKind, + string RequiredSkillId, + IReadOnlyList Inputs, + IReadOnlyList Outputs, + int? RequiredSkillLevel, + string? StationTag); diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs new file mode 100644 index 0000000..fee0958 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs @@ -0,0 +1,24 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Crafting; + +/// In-memory recipe catalog loaded at startup (NEO-66). NEO-67 adds injectable recipe definition registry for game callers. +public sealed class RecipeDefinitionCatalog( + string recipesDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_recipes.json catalogs. + public string RecipesDirectory { get; } = recipesDirectory; + + public IReadOnlyDictionary ById { get; } = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctRecipeCount => ById.Count; + + /// Number of *_recipes.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable recipe (includes embedded I/O rows). + public bool TryGetRecipe(string id, out RecipeDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs new file mode 100644 index 0000000..b3a5c78 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs @@ -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; + +/// Loads and validates content/recipes/*_recipes.json using the same rules as scripts/validate_content.py (NEO-66). +public static class RecipeDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static RecipeDefinitionCatalog Load( + string recipesDirectory, + string recipeDefSchemaPath, + string recipeIoRowSchemaPath, + IReadOnlySet knownItemIds, + IReadOnlySet knownSkillIds, + ILogger logger) + { + recipesDirectory = Path.GetFullPath(recipesDirectory); + recipeDefSchemaPath = Path.GetFullPath(recipeDefSchemaPath); + recipeIoRowSchemaPath = Path.GetFullPath(recipeIoRowSchemaPath); + + var errors = new List(); + + 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(StringComparer.Ordinal); + var idToRecipeKind = new Dictionary(StringComparer.Ordinal); + var idToRequiredSkillId = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(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(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(); + 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(); + if (recipeKind is not null) + idToRecipeKind[rid] = recipeKind; + + var requiredSkillId = (rowObj["requiredSkillId"] as JsonValue)?.GetValue(); + 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 knownItemIds, + List 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(); + 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(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var recipeKind = (rowObj["recipeKind"] as JsonValue)!.GetValue(); + var requiredSkillId = (rowObj["requiredSkillId"] as JsonValue)!.GetValue(); + 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(out var level)) + { + requiredSkillLevel = level; + } + + string? stationTag = null; + if (rowObj["stationTag"] is JsonValue stationTagValue) + stationTag = stationTagValue.GetValue(); + + return new RecipeDefRow(id, displayName, recipeKind, requiredSkillId, inputs, outputs, requiredSkillLevel, stationTag); + } + + private static IReadOnlyList ParseIoRows(JsonArray? array) + { + if (array is null) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject ioObj) + continue; + + var itemId = (ioObj["itemId"] as JsonValue)!.GetValue(); + var quantity = (ioObj["quantity"] as JsonValue)!.GetValue(); + rows.Add(new RecipeIoRow(itemId, quantity)); + } + + return rows; + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List 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 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()); + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeIoRow.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeIoRow.cs new file mode 100644 index 0000000..e58984f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeIoRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// One validated input or output row on a (NEO-66). +public sealed record RecipeIoRow(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 9a51b30..4655375 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -58,4 +58,22 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of resource-nodes directory}/schemas/resource-yield-row.schema.json. /// public string? ResourceYieldRowSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/recipes directory. + /// + public string? RecipesDirectory { get; set; } + + /// + /// Optional override for recipe-def.schema.json. + /// When unset, resolved as {parent of recipes directory}/schemas/recipe-def.schema.json. + /// + public string? RecipeDefSchemaPath { get; set; } + + /// + /// Optional override for recipe-io-row.schema.json. + /// When unset, resolved as {parent of recipes directory}/schemas/recipe-io-row.schema.json. + /// + public string? RecipeIoRowSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 8e7a00a..2f54b74 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index fe7bede..3e9cd5a 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -10,7 +10,10 @@ "SkillsDirectory": "", "SkillDefSchemaPath": "", "ItemsDirectory": "", - "ItemDefSchemaPath": "" + "ItemDefSchemaPath": "", + "RecipesDirectory": "", + "RecipeDefSchemaPath": "", + "RecipeIoRowSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index 54cd76e..973f3c7 100644 --- a/server/README.md +++ b/server/README.md @@ -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/`.