Merge pull request #102 from ViPro-Technologies/NEO-68-get-world-recipe-definitions-bruno

NEO-68: GET world recipe-definitions + Bruno
pull/105/head
VinPropane 2026-05-24 17:12:40 -04:00 committed by GitHub
commit 9264b74188
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 475 additions and 4 deletions

View File

@ -0,0 +1,81 @@
meta {
name: GET recipe definitions
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/world/recipe-definitions
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and recipes array", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.recipes).to.be.an("array");
expect(body.recipes.length).to.equal(8);
});
test("recipes are ascending by id (ordinal)", function () {
const body = res.getBody();
const ids = body.recipes.map((x) => x.id);
// Match server StringComparer.Ordinal (prototype ids are ASCII snake_case).
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
expect(ids).to.eql(sorted);
});
test("frozen prototype eight matches registry id order", function () {
const body = res.getBody();
const ids = body.recipes.map((x) => x.id);
expect(ids).to.eql([
"make_armor_quick",
"make_contract_token",
"make_field_stim_batch",
"make_field_stim_mk0",
"make_prototype_armor",
"make_survey_drone_kit",
"refine_scrap_efficient",
"refine_scrap_standard",
]);
});
test("frozen prototype eight is present", function () {
const body = res.getBody();
const ids = new Set(body.recipes.map((x) => x.id));
expect(ids.has("make_armor_quick")).to.equal(true);
expect(ids.has("make_contract_token")).to.equal(true);
expect(ids.has("make_field_stim_batch")).to.equal(true);
expect(ids.has("make_field_stim_mk0")).to.equal(true);
expect(ids.has("make_prototype_armor")).to.equal(true);
expect(ids.has("make_survey_drone_kit")).to.equal(true);
expect(ids.has("refine_scrap_efficient")).to.equal(true);
expect(ids.has("refine_scrap_standard")).to.equal(true);
});
test("refine_scrap_standard row matches catalog", function () {
const body = res.getBody();
const row = body.recipes.find((x) => x.id === "refine_scrap_standard");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Refine Scrap (Standard)");
expect(row.recipeKind).to.equal("process");
expect(row.requiredSkillId).to.equal("refine");
expect(row.inputs).to.eql([{ itemId: "scrap_metal_bulk", quantity: 5 }]);
expect(row.outputs).to.eql([{ itemId: "refined_plate_stock", quantity: 1 }]);
});
test("make_field_stim_mk0 row matches catalog", function () {
const body = res.getBody();
const row = body.recipes.find((x) => x.id === "make_field_stim_mk0");
expect(row).to.be.an("object");
expect(row.recipeKind).to.equal("make");
expect(row.inputs).to.eql([
{ itemId: "refined_plate_stock", quantity: 2 },
{ itemId: "scrap_metal_bulk", quantity: 1 },
]);
expect(row.outputs).to.eql([{ itemId: "field_stim_mk0", quantity: 1 }]);
});
}

View File

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

View File

@ -13,7 +13,7 @@
**Prep (NEO-42):** Server ships **`RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine`** + **`RefineSkillXpConstants`** under `server/NeonSprawl.Server/Game/Skills/` — delegates to **`SkillProgressionGrantOperations.TryApplyGrant`** with **`refine`** + **`activity`** (prototype **10** XP), same persistence and **`allowedXpSourceKinds`** rules as **`POST …/skill-progression`**. **No** `CraftRequest` / **`CraftResult`** route yet; when this module adds authoritative craft/refine success, call that helper once on success ([NEO-42 implementation plan](../../plans/NEO-42-implementation-plan.md); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)).
**E3M2-01 (NEO-65):** Frozen eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65 plan](../../plans/NEO-65-implementation-plan.md)). **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). **E3M2-03 (NEO-67):** injectable **`IRecipeDefinitionRegistry`** + DI; [NEO-67 plan](../../plans/NEO-67-implementation-plan.md).
**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). **E3M2-03 (NEO-67):** injectable **`IRecipeDefinitionRegistry`** + DI; [NEO-67 plan](../../plans/NEO-67-implementation-plan.md). **E3M2-04 (NEO-68):** **`GET /game/world/recipe-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/recipe-definitions/`. Plan: [NEO-68 implementation plan](../../plans/NEO-68-implementation-plan.md).
## Purpose

View File

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

View File

@ -0,0 +1,32 @@
# NEO-68 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-68 |
| Title | E3.M2: GET world recipe-definitions + Bruno |
| Linear | https://linear.app/neon-sprawl/issue/NEO-68/e3m2-get-world-recipe-definitions-bruno |
| Plan | `docs/plans/NEO-68-implementation-plan.md` |
| Branch | `NEO-68-get-world-recipe-definitions-bruno` |
## Preconditions
- Server built and configured with default content paths pointing at repo `content/recipes`, `content/items`, and `content/skills` (local dev / `InMemoryWebApplicationFactory` tests use the same layout).
## Checklist
1. Start **`NeonSprawl.Server`** (e.g. `dotnet run` from `server/NeonSprawl.Server`).
2. **`GET /game/world/recipe-definitions`** — expect **200** and **`Content-Type`** containing **`application/json`**. Example (default dev URL from `Properties/launchSettings.json` and Bruno `environments/Local.bru`; change the host/port if yours differs):
```bash
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
```
3. Parse JSON — expect **`schemaVersion`** === **1**, **`recipes`** array length **8**.
4. Confirm **`id`** values match the frozen prototype eight in **registry id order** (ordinal):
**`make_armor_quick`**, **`make_contract_token`**, **`make_field_stim_batch`**, **`make_field_stim_mk0`**, **`make_prototype_armor`**, **`make_survey_drone_kit`**, **`refine_scrap_efficient`**, **`refine_scrap_standard`**
(same sequence as `RecipeDefinitionsWorldApiTests.FrozenEightInIdOrder`).
5. Spot-check **`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 }]`.
6. Spot-check **`make_field_stim_mk0`**: **`recipeKind`** **`make`**, two **`inputs`** rows (`refined_plate_stock` × 2, `scrap_metal_bulk` × 1), **`outputs`** `[{ itemId: field_stim_mk0, quantity: 1 }]`.
7. Optional: run **`bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru`** against the same **`baseUrl`** (see `environments/Local.bru`).

View File

@ -149,8 +149,10 @@ Working backlog for **Epic 3 — Slice 3** ([recipes and crafting pipeline](../d
**Acceptance criteria**
- [ ] GET returns all eight prototype recipes with schema version field and input/output summary fields needed for QA.
- [ ] Bruno happy path documented.
- [x] GET returns all eight prototype recipes with schema version field and input/output summary fields needed for QA.
- [x] Bruno happy path documented.
**Landed ([NEO-68](https://linear.app/neon-sprawl/issue/NEO-68)):** `GET /game/world/recipe-definitions``RecipeDefinitionsWorldApi` + DTOs in `server/NeonSprawl.Server/Game/Crafting/`; plan [NEO-68-implementation-plan.md](NEO-68-implementation-plan.md); manual QA [`NEO-68`](../manual-qa/NEO-68.md); Bruno `bruno/neon-sprawl-server/recipe-definitions/`.
---

View File

@ -0,0 +1,113 @@
# NEO-68 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-68 |
| **Title** | E3.M2: GET world recipe-definitions + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-68/e3m2-get-world-recipe-definitions-bruno |
| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 Slice 3 · backlog **E3M2-04** |
| **Branch** | `NEO-68-get-world-recipe-definitions-bruno` |
| **Precursor** | [NEO-67](https://linear.app/neon-sprawl/issue/NEO-67) — `IRecipeDefinitionRegistry` + DI (**Done** on `main`) |
| **Pattern** | [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — `GET /game/world/item-definitions` |
| **Client counterpart** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) — E3S5-03 craft UI + recipe list (blocked by this issue; server-only in this story) |
| **Blocks** | [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) (Linear relations) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **JSON row fields** | Expose required six + I/O only vs optional nullables? | **Required six + `inputs`/`outputs` only** — prototype rows omit `requiredSkillLevel` / `stationTag`; mirror NEO-53 “loaded fields only”. | **User:** required six + I/O only. |
| **Response envelope** | Array property name? | **`recipes`** (with `schemaVersion` **1**) — parallel to NEO-36 `skills` / NEO-53 `items`; matches content vocabulary. | **Adopted** — no objection; follows precedent. |
| **Route path** | `/game/world/…` vs `/game/content/…`? | **`GET /game/world/recipe-definitions`** — E3M2 backlog + parallel to item/skill defs. | **Adopted** — backlog. |
| **Manual QA doc** | Add `docs/manual-qa/NEO-68.md`? | **Yes** — player-visible HTTP; NEO-53 pattern. | **User:** yes. |
## Goal, scope, and out-of-scope
**Goal:** Expose a stable, read-only JSON endpoint that returns all eight loaded prototype recipe definitions for Bruno, manual QA, client preview ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)), and tooling — backed by **`IRecipeDefinitionRegistry`** without duplicating catalog truth.
**In scope (from Linear + [E3M2-04](E3M2-prototype-backlog.md#e3m2-04--get-world-recipe-definitions)):**
- **`GET /game/world/recipe-definitions`** with versioned envelope (`schemaVersion` **1**, **`recipes`** array).
- Row fields: **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, **`inputs`**, **`outputs`** (I/O rows: **`itemId`**, **`quantity`**; camelCase JSON).
- Recipes ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
- Bruno folder `bruno/neon-sprawl-server/recipe-definitions/`.
- `docs/manual-qa/NEO-68.md`.
- `server/README.md` section.
- API integration tests (AAA).
**Out of scope (from Linear + backlog):**
- Per-player craft history or cooldown state.
- Optional schema fields (`requiredSkillLevel`, `stationTag`) until content ships them.
- Craft execution ([NEO-69](https://linear.app/neon-sprawl/issue/NEO-69)).
- Godot client ([NEO-74](https://linear.app/neon-sprawl/issue/NEO-74)).
## Acceptance criteria checklist
- [x] GET returns all **eight** prototype recipes with **`schemaVersion`** **1** and input/output summary fields needed for QA.
- [x] Bruno happy path documented and runnable against dev server.
- [x] Automated API tests (AAA) assert eight ids, id order, and spot-check metadata + I/O rows.
## Technical approach
1. **Route:** **`GET /game/world/recipe-definitions`** — parameterless read; inject **`IRecipeDefinitionRegistry`** only (not `RecipeDefinitionCatalog`).
2. **Response shape:** Top-level **`schemaVersion`** (`RecipeDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`recipes`** array. Each row maps from **`RecipeDefRow`**: **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, **`inputs`**, **`outputs`**. Do **not** emit optional null fields on this contract.
3. **I/O projection:** Map **`RecipeIoRow`** → JSON with **`itemId`**, **`quantity`** (same keys as content schema).
4. **Ordering:** Build list from **`registry.GetDefinitionsInIdOrder()`** (already ordinal by id). Tests assert exact id sequence (frozen eight from [E3.M2 snapshot](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md#prototype-slice-3-freeze-e3m2-01)):
`make_armor_quick`, `make_contract_token`, `make_field_stim_batch`, `make_field_stim_mk0`, `make_prototype_armor`, `make_survey_drone_kit`, `refine_scrap_efficient`, `refine_scrap_standard`.
5. **Implementation:** New **`RecipeDefinitionsWorldApi`** + **`RecipeDefinitionsListDtos.cs`** in `Game/Crafting/` (mirror [`ItemDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Items/ItemDefinitionsWorldApi.cs)). Wire **`app.MapRecipeDefinitionsWorldApi()`** from **`Program.cs`** next to **`MapItemDefinitionsWorldApi()`**.
6. **Bruno:** `bruno/neon-sprawl-server/recipe-definitions/` with **`Get recipe definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`recipes.length === 8`**, ascending id order, **exact frozen-eight registry sequence** (mirror NEO-53 item-definitions Bruno), spot-check **`refine_scrap_standard`** (`recipeKind` `process`, inputs/outputs) and **`make_field_stim_mk0`** (`recipeKind` `make`, multi-input).
7. **Docs:** `docs/manual-qa/NEO-68.md` (mirror NEO-53). Update **`server/README.md`** recipe section with GET + curl. Update [E3_M2](E3_M2_RefinementAndRecipeExecution.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M2 row when landed.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON. |
| `server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsListDtos.cs` | Versioned response + row + I/O DTOs for JSON serialization. |
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, eight ids in order, spot-check rows + I/O. |
| `bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru` | Manual / Bruno verification against dev server. |
| `bruno/neon-sprawl-server/recipe-definitions/folder.bru` | Bruno folder metadata (match `item-definitions`). |
| `docs/manual-qa/NEO-68.md` | Checklist: start server, curl GET, confirm eight ids + spot-check I/O. |
| `docs/plans/NEO-68-implementation-plan.md` | This plan. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `MapRecipeDefinitionsWorldApi()` alongside other game world definition APIs. |
| `server/README.md` | Document `GET /game/world/recipe-definitions`, curl example, links to plan/manual QA/Bruno. |
| `docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md` | **Related implementation slices** — HTTP read model bullet (NEO-68). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M2 row — note NEO-68 HTTP projection when landed. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs` | **Integration:** `GET /game/world/recipe-definitions` via **`InMemoryWebApplicationFactory`**; **`ReadFromJsonAsync`**; assert **`schemaVersion` 1**; **`recipes`** count **8**; ordered **`id`** list equals **`FrozenEightInIdOrder`** (shared constant referenced by manual QA / Bruno); spot-check **`refine_scrap_standard`** and **`make_field_stim_mk0`**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
Bruno scripts complement automated tests for manual dev-server verification.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Linear blockedBy NEO-67** | Branch from **`main`** where NEO-67 is merged (confirmed at kickoff). | **adopted** |
| **Optional fields later** | When content adds `requiredSkillLevel` / `stationTag`, bump **`schemaVersion`** or extend v1 with documented null semantics in a follow-up issue. | **deferred** |
| **Client not in this story** | Cross-link **NEO-74** in plan; Bruno + manual QA are not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md). | **adopted** |
## Decisions (kickoff)
- **Required six + I/O only** — no optional null fields on v1 contract (user confirmed).
- **`recipes` array key** — consistent with entity naming in sibling world-definition endpoints.
- **`docs/manual-qa/NEO-68.md` included** (user confirmed).
- **Registry-only injection** — HTTP layer depends on **`IRecipeDefinitionRegistry`**, not **`RecipeDefinitionCatalog`**.

View File

@ -0,0 +1,69 @@
# Code review — NEO-68 GET world recipe-definitions (kickoff plan)
**Date:** 2026-05-24
**Scope:** Branch `NEO-68-get-world-recipe-definitions-bruno` · commit `6de6a17` vs `origin/main` (implementation plan only — no server/Bruno/test code yet)
**Base:** `origin/main`
## Verdict
**Approve** — kickoff plan is ready to implement; **re-review required** once HTTP/DTO/tests/Bruno land.
## Summary
The branch adds only [`docs/plans/NEO-68-implementation-plan.md`](../plans/NEO-68-implementation-plan.md): kickoff clarifications, acceptance criteria, and a technical approach that mirrors the landed [NEO-53](../plans/NEO-53-implementation-plan.md) item-definitions pattern. Scope is correctly bounded to **`GET /game/world/recipe-definitions`** backed by **`IRecipeDefinitionRegistry`** (NEO-67 on `main`), with **`schemaVersion` 1** / **`recipes`** envelope, six required row fields plus I/O arrays, Bruno, manual QA, and AAA integration tests. Client work is explicitly deferred to **NEO-74**. No correctness or security risk yet — there is no runtime code on this branch.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-68-implementation-plan.md`](../plans/NEO-68-implementation-plan.md) | **Matches** — kickoff decisions recorded; files-to-add/modify list complete; registry-only injection; frozen eight id order documented; NEO-74 cross-link present. |
| [`docs/plans/E3M2-prototype-backlog.md`](../plans/E3M2-prototype-backlog.md) · **E3M2-04** | **Matches** — route, DTOs, API tests, Bruno, README align with backlog; plan adds `docs/manual-qa/NEO-68.md` (appropriate for player-visible HTTP, NEO-53 precedent). |
| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Partially matches** — freeze table and eight ids match plan test assertions; **Related implementation slices** HTTP bullet correctly listed as a modify-on-land item. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Partially matches** — E3.M2 row update deferred until implementation (correct). |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E3.M2 **Status** still **Planned** (same as postNEO-67); optional bump when Slice 3 server spine completes. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** (planned) — read-only world catalog; server source of truth; no client mutation in this story. |
| NEO-53 reference implementation | **Matches** — plan cites [`ItemDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Items/ItemDefinitionsWorldApi.cs), DTO pattern, Bruno folder layout, and integration test shape. |
| Full-stack epic decomposition | **Matches****NEO-74** documented as client counterpart; plan does not claim prototype slice complete without Godot. |
Register/tracking: no alignment updates expected until implementation merges.
## Blocking issues
None (plan-only scope).
## Suggestions
1. ~~**Bruno — exact frozen-eight id order** — Plan section 6 requires ascending order and frozen eight presence. After NEO-53 review, [`Get item definitions.bru`](../../bruno/neon-sprawl-server/item-definitions/Get%20item%20definitions.bru) also asserts the **exact** registry sequence (not just sort + set membership). Mirror that with a `frozen prototype eight matches registry id order` Bruno test using the plans frozen list so manual runs catch ordering regressions without `dotnet test`.~~ **Done.** Bruno test asserts exact frozen-eight sequence.
2. ~~**Shared frozen id constant** — Follow [`ItemDefinitionsWorldApiTests`](../../server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionsWorldApiTests.cs): define a `FrozenEightInIdOrder` string array in the integration test file and reference the same sequence in manual QA / Bruno docs to avoid drift from the E3.M2 freeze table.~~ **Done.** `RecipeDefinitionsWorldApiTests.FrozenEightInIdOrder`; manual QA and Bruno mirror the sequence.
3. ~~**E3M2 backlog checkboxes on land** — When implementation merges, update **E3M2-04** acceptance checkboxes and landed note in [`E3M2-prototype-backlog.md`](../plans/E3M2-prototype-backlog.md) (same pattern as NEO-66/67).~~ **Done.**
## Nits
- ~~Nit: Plan lists **`RecipeDefinitionsListDtos.cs`** as a separate file — NEO-53 keeps DTOs in one file with the API class; either layout is fine; pick one and stay consistent with the Items folder convention.~~ **Done.** Separate DTO file matches NEO-53 (`ItemDefinitionsListDtos.cs` + `ItemDefinitionsWorldApi.cs`).
- ~~Nit: Manual QA template in plan says “mirror NEO-53” — include the metadata table header (`Key`, `Title`, `Linear`, `Plan`, `Branch`) from [`docs/manual-qa/NEO-53.md`](../manual-qa/NEO-53.md) for consistency.~~ **Done.**
## Verification
Plan phase (current):
```bash
# Confirm branch is plan-only vs main
git diff origin/main...HEAD --stat
```
After implementation (required before merge):
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~RecipeDefinitionsWorldApiTests"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual:
- Start server; `curl -sS "http://localhost:5253/game/world/recipe-definitions"`**200**, **`schemaVersion` 1**, **`recipes.length` 8**, frozen eight ids in ordinal order.
- Run `bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru` against `environments/Local.bru`.
- Walk through `docs/manual-qa/NEO-68.md`.

View File

@ -0,0 +1,60 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Crafting;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Crafting;
public class RecipeDefinitionsWorldApiTests
{
/// <summary>Frozen prototype eight in registry id order (ordinal). Keep in sync with manual QA and Bruno.</summary>
public static readonly string[] FrozenEightInIdOrder =
[
"make_armor_quick",
"make_contract_token",
"make_field_stim_batch",
"make_field_stim_mk0",
"make_prototype_armor",
"make_survey_drone_kit",
"refine_scrap_efficient",
"refine_scrap_standard",
];
[Fact]
public async Task GetRecipeDefinitions_ShouldReturnSchemaV1_WithFrozenEightInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/recipe-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<RecipeDefinitionsListResponse>();
Assert.NotNull(body);
Assert.Equal(RecipeDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Recipes);
var ids = body.Recipes.Select(static r => r.Id).ToList();
Assert.Equal(FrozenEightInIdOrder, ids);
var refine = body.Recipes.Single(r => r.Id == "refine_scrap_standard");
Assert.Equal("Refine Scrap (Standard)", refine.DisplayName);
Assert.Equal("process", refine.RecipeKind);
Assert.Equal("refine", refine.RequiredSkillId);
Assert.Single(refine.Inputs);
Assert.Equal("scrap_metal_bulk", refine.Inputs[0].ItemId);
Assert.Equal(5, refine.Inputs[0].Quantity);
Assert.Single(refine.Outputs);
Assert.Equal("refined_plate_stock", refine.Outputs[0].ItemId);
Assert.Equal(1, refine.Outputs[0].Quantity);
var stim = body.Recipes.Single(r => r.Id == "make_field_stim_mk0");
Assert.Equal("make", stim.RecipeKind);
Assert.Equal(2, stim.Inputs.Count);
Assert.Equal("refined_plate_stock", stim.Inputs[0].ItemId);
Assert.Equal(2, stim.Inputs[0].Quantity);
Assert.Equal("scrap_metal_bulk", stim.Inputs[1].ItemId);
Assert.Equal(1, stim.Inputs[1].Quantity);
}
}

View File

@ -0,0 +1,48 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>JSON body for <c>GET /game/world/recipe-definitions</c> (NEO-68).</summary>
public sealed class RecipeDefinitionsListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Loaded recipes ordered by stable <c>id</c> (ordinal), matching <see cref="IRecipeDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
[JsonPropertyName("recipes")]
public required IReadOnlyList<RecipeDefinitionJson> Recipes { get; init; }
}
/// <summary>One row in the read-only recipe definition projection.</summary>
public sealed class RecipeDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("recipeKind")]
public required string RecipeKind { get; init; }
[JsonPropertyName("requiredSkillId")]
public required string RequiredSkillId { get; init; }
[JsonPropertyName("inputs")]
public required IReadOnlyList<RecipeIoJson> Inputs { get; init; }
[JsonPropertyName("outputs")]
public required IReadOnlyList<RecipeIoJson> Outputs { get; init; }
}
/// <summary>One input or output row in the read-only recipe definition projection.</summary>
public sealed class RecipeIoJson
{
[JsonPropertyName("itemId")]
public required string ItemId { get; init; }
[JsonPropertyName("quantity")]
public required int Quantity { get; init; }
}

View File

@ -0,0 +1,54 @@
namespace NeonSprawl.Server.Game.Crafting;
/// <summary>Maps <c>GET /game/world/recipe-definitions</c> (NEO-68).</summary>
public static class RecipeDefinitionsWorldApi
{
public static WebApplication MapRecipeDefinitionsWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/recipe-definitions",
(IRecipeDefinitionRegistry registry) =>
{
var defs = registry.GetDefinitionsInIdOrder();
var recipes = new List<RecipeDefinitionJson>(defs.Count);
foreach (var d in defs)
{
recipes.Add(
new RecipeDefinitionJson
{
Id = d.Id,
DisplayName = d.DisplayName,
RecipeKind = d.RecipeKind,
RequiredSkillId = d.RequiredSkillId,
Inputs = MapIoRows(d.Inputs),
Outputs = MapIoRows(d.Outputs),
});
}
return Results.Json(
new RecipeDefinitionsListResponse
{
SchemaVersion = RecipeDefinitionsListResponse.CurrentSchemaVersion,
Recipes = recipes,
});
});
return app;
}
private static List<RecipeIoJson> MapIoRows(IReadOnlyList<RecipeIoRow> rows)
{
var list = new List<RecipeIoJson>(rows.Count);
foreach (var row in rows)
{
list.Add(
new RecipeIoJson
{
ItemId = row.ItemId,
Quantity = row.Quantity,
});
}
return list;
}
}

View File

@ -44,6 +44,7 @@ app.MapInteractionApi();
app.MapInteractablesWorldApi();
app.MapSkillDefinitionsWorldApi();
app.MapItemDefinitionsWorldApi();
app.MapRecipeDefinitionsWorldApi();
app.MapResourceNodeDefinitionsWorldApi();
app.MapPlayerInventoryApi();
app.MapSkillProgressionSnapshotApi();

View File

@ -80,6 +80,14 @@ On startup the host loads every **`*_recipes.json`** under the recipes directory
On success, **Information** logs include the resolved recipes directory path, distinct recipe count, and catalog file count. Game code should use **`IRecipeDefinitionRegistry`** for lookups (NEO-67). The catalog singleton remains for fail-fast startup only; do not inject **`RecipeDefinitionCatalog`** in new game code.
## Recipe definitions (NEO-68)
**`GET /game/world/recipe-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`recipes`**) backed by **`IRecipeDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`recipeKind`**, **`requiredSkillId`**, and nested **`inputs`** / **`outputs`** (`itemId`, `quantity`). Plan: [NEO-68 implementation plan](../../docs/plans/NEO-68-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-68.md`](../../docs/manual-qa/NEO-68.md); Bruno: `bruno/neon-sprawl-server/recipe-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/recipe-definitions"
```
## 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/`.