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.pull/100/head
parent
ccf4cf8e5c
commit
f209636e7e
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: recipe-catalog
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<string> KnownItemIds = PrototypeSlice1ItemCatalogRules.ExpectedItemIds;
|
||||
|
||||
private static readonly FrozenSet<string> KnownSkillIds = FrozenSet.ToFrozenSet(
|
||||
["salvage", "refine", "intrusion"],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "refine_scrap_standard",
|
||||
"displayName": "Refine Scrap (Standard)",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "refine_scrap_efficient",
|
||||
"displayName": "Refine Scrap (Efficient)",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
|
||||
},
|
||||
{
|
||||
"id": "make_field_stim_mk0",
|
||||
"displayName": "Make Field Stim Mk0",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 2 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 1 }
|
||||
],
|
||||
"outputs": [{ "itemId": "field_stim_mk0", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_field_stim_batch",
|
||||
"displayName": "Make Field Stim (Batch)",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 5 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 3 }
|
||||
],
|
||||
"outputs": [{ "itemId": "field_stim_mk0", "quantity": 3 }]
|
||||
},
|
||||
{
|
||||
"id": "make_prototype_armor",
|
||||
"displayName": "Make Prototype Armor Shell",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 8 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 5 }
|
||||
],
|
||||
"outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_armor_quick",
|
||||
"displayName": "Make Armor (Quick)",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 4 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 3 }
|
||||
],
|
||||
"outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_survey_drone_kit",
|
||||
"displayName": "Make Survey Drone Kit",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 3 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 2 }
|
||||
],
|
||||
"outputs": [{ "itemId": "survey_drone_kit", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "make_contract_token",
|
||||
"displayName": "Make Contract Handoff Token",
|
||||
"recipeKind": "make",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [
|
||||
{ "itemId": "refined_plate_stock", "quantity": 2 },
|
||||
{ "itemId": "scrap_metal_bulk", "quantity": 1 }
|
||||
],
|
||||
"outputs": [{ "itemId": "contract_handoff_token", "quantity": 1 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string RecipesDir, string DefSchemaPath, string IoSchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-recipecat-");
|
||||
var recipesDir = Path.Combine(root.FullName, "content", "recipes");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(recipesDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var defSchemaPath = Path.Combine(schemaDir, "recipe-def.schema.json");
|
||||
var ioSchemaPath = Path.Combine(schemaDir, "recipe-io-row.schema.json");
|
||||
File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath(), defSchemaPath, overwrite: true);
|
||||
File.Copy(RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath(), ioSchemaPath, overwrite: true);
|
||||
return (root.FullName, recipesDir, defSchemaPath, ioSchemaPath);
|
||||
}
|
||||
|
||||
private static void WriteCatalog(string recipesDir, string catalogJson) =>
|
||||
File.WriteAllText(Path.Combine(recipesDir, "prototype_recipes.json"), catalogJson, Encoding.UTF8);
|
||||
|
||||
private static JsonObject GetRecipeRow(JsonObject catalogRoot, string recipeId)
|
||||
{
|
||||
var recipes = catalogRoot["recipes"] as JsonArray
|
||||
?? throw new InvalidOperationException("expected recipes array");
|
||||
foreach (var node in recipes)
|
||||
{
|
||||
if (node is JsonObject row && row["id"]?.GetValue<string>() == recipeId)
|
||||
return row;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"recipe id not found: {recipeId}");
|
||||
}
|
||||
|
||||
private static RecipeDefinitionCatalog LoadCatalog(
|
||||
string recipesDir,
|
||||
string defSchemaPath,
|
||||
string ioSchemaPath) =>
|
||||
RecipeDefinitionCatalogLoader.Load(
|
||||
recipesDir,
|
||||
defSchemaPath,
|
||||
ioSchemaPath,
|
||||
KnownItemIds,
|
||||
KnownSkillIds,
|
||||
NullLogger.Instance);
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
WriteCatalog(recipesDir, ValidPrototypeCatalogJson);
|
||||
// Act
|
||||
var catalog = LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath);
|
||||
// Assert
|
||||
Assert.Equal(8, catalog.DistinctRecipeCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.TryGetRecipe("refine_scrap_standard", out var row));
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal("process", row!.RecipeKind);
|
||||
Assert.Single(row.Inputs);
|
||||
Assert.Equal("scrap_metal_bulk", row.Inputs[0].ItemId);
|
||||
Assert.Equal(5, row.Inputs[0].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRecipesIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(recipesDir, "bad_recipes.json"),
|
||||
"""{"schemaVersion": 1, "recipes": "nope"}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'recipes' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(
|
||||
Path.Combine(recipesDir, "bad_recipes.json"),
|
||||
"""{"schemaVersion": 2, "recipes": []}""",
|
||||
Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRowViolatesSchema()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
const string bad = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "refine_scrap_standard",
|
||||
"displayName": "",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(recipesDir, "bad_recipes.json"), bad, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad_recipes.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Recipe catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
const string singleRecipe = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "refine_scrap_standard",
|
||||
"displayName": "Refine Scrap (Standard)",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(recipesDir, "a_recipes.json"), singleRecipe, Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(recipesDir, "b_recipes.json"), singleRecipe, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate recipe id", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("refine_scrap_standard", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSlice3IdsIncomplete()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
const string twoOnly = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "refine_scrap_standard",
|
||||
"displayName": "Refine Scrap (Standard)",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }]
|
||||
},
|
||||
{
|
||||
"id": "refine_scrap_efficient",
|
||||
"displayName": "Refine Scrap (Efficient)",
|
||||
"recipeKind": "process",
|
||||
"requiredSkillId": "refine",
|
||||
"inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }],
|
||||
"outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(recipesDir, "partial_recipes.json"), twoOnly, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype Slice 3 expects exactly recipe ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenMissingMakeRecipeKind()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
var recipes = root["recipes"] as JsonArray ?? throw new InvalidOperationException("expected recipes array");
|
||||
foreach (var node in recipes)
|
||||
{
|
||||
if (node is JsonObject row && row["recipeKind"]?.GetValue<string>() == "make")
|
||||
row["recipeKind"] = "process";
|
||||
}
|
||||
|
||||
WriteCatalog(recipesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("requires at least one process and one make recipe", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRequiredSkillIdIsNotRefine()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetRecipeRow(root, "make_field_stim_mk0")["requiredSkillId"] = "salvage";
|
||||
WriteCatalog(recipesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("make_field_stim_mk0", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("requiredSkillId must be 'refine'", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenUnknownItemIdInInputs()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
var row = GetRecipeRow(root, "refine_scrap_standard");
|
||||
var inputs = row["inputs"] as JsonArray ?? throw new InvalidOperationException("expected inputs array");
|
||||
inputs[0] = JsonNode.Parse("""{"itemId": "unknown_item_xyz", "quantity": 5}""");
|
||||
WriteCatalog(recipesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("unknown_item_xyz", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("is not in item catalogs", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenUnknownRequiredSkillId()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, defSchemaPath, ioSchemaPath) = CreateTempContentLayout();
|
||||
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
|
||||
?? throw new InvalidOperationException("expected object root");
|
||||
GetRecipeRow(root, "refine_scrap_standard")["requiredSkillId"] = "unknown_skill_xyz";
|
||||
WriteCatalog(recipesDir, root.ToJsonString());
|
||||
// Act
|
||||
var ex = Record.Exception(() => LoadCatalog(recipesDir, defSchemaPath, ioSchemaPath));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("unknown_skill_xyz", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("is not a known SkillDef id", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRecipesDirectoryMissing()
|
||||
{
|
||||
// Arrange
|
||||
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-recipes-" + Guid.NewGuid().ToString("n"));
|
||||
var defSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeDefSchemaPath();
|
||||
var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath();
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
RecipeDefinitionCatalogLoader.Load(
|
||||
missingDir,
|
||||
defSchemaPath,
|
||||
ioSchemaPath,
|
||||
KnownItemIds,
|
||||
KnownSkillIds,
|
||||
NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSchemaFileMissing()
|
||||
{
|
||||
// Arrange
|
||||
var (_, recipesDir, _, _) = CreateTempContentLayout();
|
||||
var missingSchema = Path.Combine(recipesDir, "missing-recipe-def.schema.json");
|
||||
var ioSchemaPath = RecipeCatalogTestPaths.DiscoverRepoRecipeIoRowSchemaPath();
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
RecipeDefinitionCatalogLoader.Load(
|
||||
recipesDir,
|
||||
missingSchema,
|
||||
ioSchemaPath,
|
||||
KnownItemIds,
|
||||
KnownSkillIds,
|
||||
NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
// Act
|
||||
var response = await client.GetAsync("/health");
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var catalog = factory.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
Assert.Equal(8, catalog.DistinctRecipeCount);
|
||||
Assert.True(catalog.TryGetRecipe("make_field_stim_mk0", out var stim));
|
||||
Assert.Equal("make", stim!.RecipeKind);
|
||||
Assert.Equal(2, stim.Inputs.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-recipes-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(badDir);
|
||||
try
|
||||
{
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
b.UseSetting("Content:RecipesDirectory", badDir));
|
||||
factory.CreateClient();
|
||||
});
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
Assert.Contains("Recipe catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(badDir))
|
||||
Directory.Delete(badDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
|
@ -38,10 +39,14 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
var recipesDir = RecipeCatalogPathResolution.TryDiscoverRecipesDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/recipes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Content:RecipesDirectory", recipesDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype Slice 3 roster gate (NEO-65), mirrored from <c>scripts/validate_content.py</c>
|
||||
/// <c>PROTOTYPE_SLICE3_RECIPE_IDS</c> / <c>_prototype_slice3_recipe_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeSlice3RecipeCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE3_RECIPE_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedRecipeIds = FrozenSet.ToFrozenSet(
|
||||
[
|
||||
"refine_scrap_standard",
|
||||
"refine_scrap_efficient",
|
||||
"make_field_stim_mk0",
|
||||
"make_field_stim_batch",
|
||||
"make_prototype_armor",
|
||||
"make_armor_quick",
|
||||
"make_survey_drone_kit",
|
||||
"make_contract_token",
|
||||
],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE3_REFINE_SKILL_ID</c>.</summary>
|
||||
public const string ExpectedRefineSkillId = "refine";
|
||||
|
||||
/// <summary>Returns a human-readable error if the Slice 3 recipe contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetSlice3GateError(
|
||||
IReadOnlyDictionary<string, string> recipeIdToSourceFile,
|
||||
IReadOnlyDictionary<string, string> idToRecipeKind,
|
||||
IReadOnlyDictionary<string, string> idToRequiredSkillId)
|
||||
{
|
||||
var ids = recipeIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedRecipeIds))
|
||||
{
|
||||
return
|
||||
"error: prototype Slice 3 expects exactly recipe ids " +
|
||||
$"[{string.Join(", ", ExpectedRecipeIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
var kinds = idToRecipeKind.Values.ToHashSet(StringComparer.Ordinal);
|
||||
if (!kinds.Contains("process") || !kinds.Contains("make"))
|
||||
{
|
||||
return
|
||||
"error: prototype Slice 3 requires at least one process and one make recipe; " +
|
||||
$"recipeKind values seen: [{string.Join(", ", kinds.Order(StringComparer.Ordinal).Select(k => "'" + k + "'"))}]";
|
||||
}
|
||||
|
||||
foreach (var (rid, skillId) in idToRequiredSkillId)
|
||||
{
|
||||
if (skillId != ExpectedRefineSkillId)
|
||||
{
|
||||
return
|
||||
$"error: '{rid}' requiredSkillId must be '{ExpectedRefineSkillId}', got '{skillId}'";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>Resolves recipe catalog paths for local dev, tests, and container layouts (NEO-66).</summary>
|
||||
public static class RecipeCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/recipes</c> directory.</summary>
|
||||
public static string? TryDiscoverRecipesDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "recipes");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the recipes catalog directory.
|
||||
/// Empty <paramref name="configuredRecipesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveRecipesDirectory(string? configuredRecipesDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredRecipesDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverRecipesDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:RecipesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/recipes'). " +
|
||||
"Set Content:RecipesDirectory in configuration or environment (e.g. Content__RecipesDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredRecipesDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single recipe row (Draft 2020-12).</summary>
|
||||
public static string ResolveRecipeDefSchemaPath(
|
||||
string recipesDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(recipesDirectory, "..", "schemas", "recipe-def.schema.json"));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a recipe I/O row (Draft 2020-12).</summary>
|
||||
public static string ResolveRecipeIoRowSchemaPath(
|
||||
string recipesDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(recipesDirectory, "..", "schemas", "recipe-io-row.schema.json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>DI registration for the fail-fast recipe catalog (NEO-66).</summary>
|
||||
public static class RecipeCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="RecipeDefinitionCatalog"/> as a singleton.</summary>
|
||||
public static IServiceCollection AddRecipeDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<RecipeDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
|
||||
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Crafting.RecipeCatalog");
|
||||
|
||||
var recipesDir = RecipeCatalogPathResolution.ResolveRecipesDirectory(
|
||||
opts.RecipesDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var recipeDefSchemaPath = RecipeCatalogPathResolution.ResolveRecipeDefSchemaPath(
|
||||
recipesDir,
|
||||
opts.RecipeDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
var recipeIoRowSchemaPath = RecipeCatalogPathResolution.ResolveRecipeIoRowSchemaPath(
|
||||
recipesDir,
|
||||
opts.RecipeIoRowSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
var knownSkillIds = skillCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return RecipeDefinitionCatalogLoader.Load(
|
||||
recipesDir,
|
||||
recipeDefSchemaPath,
|
||||
recipeIoRowSchemaPath,
|
||||
knownItemIds,
|
||||
knownSkillIds,
|
||||
logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>One validated <c>RecipeDef</c> row from <c>content/recipes/*_recipes.json</c> (NEO-66).</summary>
|
||||
public sealed record RecipeDefRow(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string RecipeKind,
|
||||
string RequiredSkillId,
|
||||
IReadOnlyList<RecipeIoRow> Inputs,
|
||||
IReadOnlyList<RecipeIoRow> Outputs,
|
||||
int? RequiredSkillLevel,
|
||||
string? StationTag);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>In-memory recipe catalog loaded at startup (NEO-66). NEO-67 adds injectable recipe definition registry for game callers.</summary>
|
||||
public sealed class RecipeDefinitionCatalog(
|
||||
string recipesDirectory,
|
||||
IReadOnlyDictionary<string, RecipeDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*_recipes.json</c> catalogs.</summary>
|
||||
public string RecipesDirectory { get; } = recipesDirectory;
|
||||
|
||||
public IReadOnlyDictionary<string, RecipeDefRow> ById { get; } = new ReadOnlyDictionary<string, RecipeDefRow>(new Dictionary<string, RecipeDefRow>(byId, StringComparer.Ordinal));
|
||||
|
||||
public int DistinctRecipeCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_recipes.json</c> files under <see cref="RecipesDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
|
||||
|
||||
/// <summary>Resolves a catalog row by stable recipe <paramref name="id"/> (includes embedded I/O rows).</summary>
|
||||
public bool TryGetRecipe(string id, out RecipeDefRow? row) =>
|
||||
ById.TryGetValue(id, out row);
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>Loads and validates <c>content/recipes/*_recipes.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-66).</summary>
|
||||
public static class RecipeDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static RecipeDefinitionCatalog Load(
|
||||
string recipesDirectory,
|
||||
string recipeDefSchemaPath,
|
||||
string recipeIoRowSchemaPath,
|
||||
IReadOnlySet<string> knownItemIds,
|
||||
IReadOnlySet<string> knownSkillIds,
|
||||
ILogger logger)
|
||||
{
|
||||
recipesDirectory = Path.GetFullPath(recipesDirectory);
|
||||
recipeDefSchemaPath = Path.GetFullPath(recipeDefSchemaPath);
|
||||
recipeIoRowSchemaPath = Path.GetFullPath(recipeIoRowSchemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(recipeDefSchemaPath))
|
||||
errors.Add($"error: missing schema file {recipeDefSchemaPath}");
|
||||
|
||||
if (!File.Exists(recipeIoRowSchemaPath))
|
||||
errors.Add($"error: missing schema file {recipeIoRowSchemaPath}");
|
||||
|
||||
if (!Directory.Exists(recipesDirectory))
|
||||
errors.Add($"error: missing directory {recipesDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(recipesDirectory, "*_recipes.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no *_recipes.json files under {recipesDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var ioSchemaText = File.ReadAllText(recipeIoRowSchemaPath);
|
||||
var defSchemaText = File.ReadAllText(recipeDefSchemaPath);
|
||||
var ioSchema = JsonSchema.FromText(ioSchemaText);
|
||||
SchemaRegistry.Global.Register(ioSchema);
|
||||
var defSchema = JsonSchema.FromText(defSchemaText);
|
||||
SchemaRegistry.Global.Register(defSchema);
|
||||
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var recipeIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var idToRecipeKind = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var idToRequiredSkillId = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, RecipeDefRow>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in jsonFiles)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root is not JsonObject rootObj)
|
||||
{
|
||||
errors.Add($"error: {path}: expected JSON object at root");
|
||||
continue;
|
||||
}
|
||||
|
||||
var schemaVersionNode = rootObj["schemaVersion"];
|
||||
if (schemaVersionNode is not JsonValue schemaVersionValue ||
|
||||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
|
||||
schemaVersion != 1)
|
||||
{
|
||||
var got = schemaVersionNode?.ToJsonString() ?? "null";
|
||||
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var recipesNode = rootObj["recipes"];
|
||||
if (recipesNode is not JsonArray recipesArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'recipes' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < recipesArray.Count; i++)
|
||||
{
|
||||
var recipe = recipesArray[i];
|
||||
if (recipe is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: recipes[{i}] must be an object");
|
||||
continue;
|
||||
}
|
||||
|
||||
var eval = defSchema.Evaluate(rowObj, evalOptions);
|
||||
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (!eval.IsValid)
|
||||
{
|
||||
if (schemaMsgs.Count == 0)
|
||||
schemaMsgs.Add($"error: {path} recipes[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var rowSchemaErrors = schemaMsgs.Count;
|
||||
|
||||
var rid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (rid is not null && rowSchemaErrors == 0)
|
||||
{
|
||||
if (recipeIdToSourceFile.TryGetValue(rid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate recipe id '{rid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
recipeIdToSourceFile[rid] = path;
|
||||
|
||||
var recipeKind = (rowObj["recipeKind"] as JsonValue)?.GetValue<string>();
|
||||
if (recipeKind is not null)
|
||||
idToRecipeKind[rid] = recipeKind;
|
||||
|
||||
var requiredSkillId = (rowObj["requiredSkillId"] as JsonValue)?.GetValue<string>();
|
||||
if (requiredSkillId is not null)
|
||||
{
|
||||
idToRequiredSkillId[rid] = requiredSkillId;
|
||||
if (!knownSkillIds.Contains(requiredSkillId))
|
||||
{
|
||||
errors.Add(
|
||||
$"error: {path} recipes[{i}]: requiredSkillId '{requiredSkillId}' " +
|
||||
$"is not a known SkillDef id (known: [{string.Join(", ", knownSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}])");
|
||||
}
|
||||
}
|
||||
|
||||
CrossCheckIoRows(rowObj, path, i, "inputs", knownItemIds, errors);
|
||||
CrossCheckIoRows(rowObj, path, i, "outputs", knownItemIds, errors);
|
||||
|
||||
rows[rid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var slice3 = PrototypeSlice3RecipeCatalogRules.TryGetSlice3GateError(
|
||||
recipeIdToSourceFile,
|
||||
idToRecipeKind,
|
||||
idToRequiredSkillId);
|
||||
if (slice3 is not null)
|
||||
{
|
||||
errors.Add(slice3);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
if (logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Loaded recipe catalog from {RecipesDirectory}: {RecipeCount} recipe(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
recipesDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
}
|
||||
|
||||
return new RecipeDefinitionCatalog(recipesDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static void CrossCheckIoRows(
|
||||
JsonObject rowObj,
|
||||
string path,
|
||||
int recipeIndex,
|
||||
string ioKey,
|
||||
IReadOnlySet<string> knownItemIds,
|
||||
List<string> errors)
|
||||
{
|
||||
if (rowObj[ioKey] is not JsonArray ioArray)
|
||||
return;
|
||||
|
||||
for (var j = 0; j < ioArray.Count; j++)
|
||||
{
|
||||
if (ioArray[j] is not JsonObject ioRow)
|
||||
continue;
|
||||
|
||||
var itemId = (ioRow["itemId"] as JsonValue)?.GetValue<string>();
|
||||
if (itemId is not null && !knownItemIds.Contains(itemId))
|
||||
{
|
||||
errors.Add(
|
||||
$"error: {path} recipes[{recipeIndex}].{ioKey}[{j}]: itemId '{itemId}' is not in item catalogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RecipeDefRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var recipeKind = (rowObj["recipeKind"] as JsonValue)!.GetValue<string>();
|
||||
var requiredSkillId = (rowObj["requiredSkillId"] as JsonValue)!.GetValue<string>();
|
||||
var inputs = ParseIoRows(rowObj["inputs"] as JsonArray);
|
||||
var outputs = ParseIoRows(rowObj["outputs"] as JsonArray);
|
||||
|
||||
int? requiredSkillLevel = null;
|
||||
if (rowObj["requiredSkillLevel"] is JsonValue levelValue &&
|
||||
levelValue.TryGetValue<int>(out var level))
|
||||
{
|
||||
requiredSkillLevel = level;
|
||||
}
|
||||
|
||||
string? stationTag = null;
|
||||
if (rowObj["stationTag"] is JsonValue stationTagValue)
|
||||
stationTag = stationTagValue.GetValue<string>();
|
||||
|
||||
return new RecipeDefRow(id, displayName, recipeKind, requiredSkillId, inputs, outputs, requiredSkillLevel, stationTag);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RecipeIoRow> ParseIoRows(JsonArray? array)
|
||||
{
|
||||
if (array is null)
|
||||
return [];
|
||||
|
||||
var rows = new List<RecipeIoRow>(array.Count);
|
||||
foreach (var node in array)
|
||||
{
|
||||
if (node is not JsonObject ioObj)
|
||||
continue;
|
||||
|
||||
var itemId = (ioObj["itemId"] as JsonValue)!.GetValue<string>();
|
||||
var quantity = (ioObj["quantity"] as JsonValue)!.GetValue<int>();
|
||||
rows.Add(new RecipeIoRow(itemId, quantity));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||
{
|
||||
if (r.HasDetails)
|
||||
{
|
||||
foreach (var d in r.Details!)
|
||||
AppendSchemaMessages(d, filePath, index, sink);
|
||||
}
|
||||
|
||||
if (!r.HasErrors)
|
||||
return;
|
||||
|
||||
foreach (var kv in r.Errors!)
|
||||
{
|
||||
var loc = r.InstanceLocation?.ToString();
|
||||
if (string.IsNullOrEmpty(loc) || loc == "#")
|
||||
loc = "(root)";
|
||||
|
||||
sink.Add($"error: {filePath} recipes[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Recipe catalog validation failed:");
|
||||
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||
sb.AppendLine(e);
|
||||
|
||||
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Crafting;
|
||||
|
||||
/// <summary>One validated input or output row on a <see cref="RecipeDefRow"/> (NEO-66).</summary>
|
||||
public sealed record RecipeIoRow(string ItemId, int Quantity);
|
||||
|
|
@ -58,4 +58,22 @@ public sealed class ContentPathsOptions
|
|||
/// When unset, resolved as <c>{parent of resource-nodes directory}/schemas/resource-yield-row.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? ResourceYieldRowSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/recipes</c> directory.
|
||||
/// </summary>
|
||||
public string? RecipesDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>recipe-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-def.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? RecipeDefSchemaPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>recipe-io-row.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of recipes directory}/schemas/recipe-io-row.schema.json</c>.
|
||||
/// </summary>
|
||||
public string? RecipeIoRowSchemaPath { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Crafting;
|
||||
using NeonSprawl.Server.Game.Gathering;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.Items;
|
||||
|
|
@ -17,12 +18,14 @@ builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockSt
|
|||
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
||||
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@
|
|||
"SkillsDirectory": "",
|
||||
"SkillDefSchemaPath": "",
|
||||
"ItemsDirectory": "",
|
||||
"ItemDefSchemaPath": ""
|
||||
"ItemDefSchemaPath": "",
|
||||
"RecipesDirectory": "",
|
||||
"RecipeDefSchemaPath": "",
|
||||
"RecipeIoRowSchemaPath": ""
|
||||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue