From f209636e7ed395181aa5a151f2933f886ba79327 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 16:41:48 -0400 Subject: [PATCH] NEO-66: Fail-fast server recipe catalog load at startup. Add Game/Crafting loader, Slice 3 gate rules, DI registration, and AAA tests mirroring item/resource-node catalog patterns; cross-check item and skill ids. --- .../Health after recipe catalog load.bru | 25 + .../recipe-catalog/folder.bru | 3 + .../E3_M2_RefinementAndRecipeExecution.md | 2 +- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E3M2-prototype-backlog.md | 6 +- docs/plans/NEO-66-implementation-plan.md | 8 +- .../Game/Crafting/RecipeCatalogTestPaths.cs | 23 + .../RecipeDefinitionCatalogLoaderTests.cs | 464 ++++++++++++++++++ .../InMemoryWebApplicationFactory.cs | 5 + .../PrototypeSlice3RecipeCatalogRules.cs | 62 +++ .../Crafting/RecipeCatalogPathResolution.cs | 78 +++ ...ecipeCatalogServiceCollectionExtensions.cs | 53 ++ .../Game/Crafting/RecipeDefRow.cs | 12 + .../Game/Crafting/RecipeDefinitionCatalog.cs | 24 + .../Crafting/RecipeDefinitionCatalogLoader.cs | 285 +++++++++++ .../Game/Crafting/RecipeIoRow.cs | 4 + .../Game/Skills/ContentPathsOptions.cs | 18 + server/NeonSprawl.Server/Program.cs | 3 + server/NeonSprawl.Server/appsettings.json | 5 +- server/README.md | 14 + 20 files changed, 1087 insertions(+), 9 deletions(-) create mode 100644 bruno/neon-sprawl-server/recipe-catalog/Health after recipe catalog load.bru create mode 100644 bruno/neon-sprawl-server/recipe-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Crafting/RecipeCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/PrototypeSlice3RecipeCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeIoRow.cs 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 index 5e18834..f94a9b0 100644 --- a/docs/plans/NEO-66-implementation-plan.md +++ b/docs/plans/NEO-66-implementation-plan.md @@ -42,10 +42,10 @@ ## Acceptance criteria checklist -- [ ] Server refuses boot when recipe catalog invalid (missing dir, no `*_recipes.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, unknown `itemId` / `requiredSkillId`, Slice 3 gate failure). -- [ ] `RecipeDefinitionCatalog` resolves prototype rows by recipe `id` (e.g. `refine_scrap_standard`, `make_field_stim_mk0`) including embedded input/output rows. -- [ ] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. -- [ ] Success log includes **recipe count**, **catalog directory**, and **file count** (Information). +- [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 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..551da97 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionCatalogLoaderTests.cs @@ -0,0 +1,464 @@ +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_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/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/`.