From 1636fdc2cb62e18d6a443d1450006b808d4a3f56 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 17:08:15 -0400 Subject: [PATCH] NEO-68: Add GET /game/world/recipe-definitions HTTP projection. Wire RecipeDefinitionsWorldApi over IRecipeDefinitionRegistry with schema v1 DTOs, Bruno folder, manual QA, and FrozenEightInIdOrder integration test. --- .../Get recipe definitions.bru | 81 +++++++++++++++++++ .../recipe-definitions/folder.bru | 3 + docs/manual-qa/NEO-68.md | 32 ++++++++ .../RecipeDefinitionsWorldApiTests.cs | 60 ++++++++++++++ .../Crafting/RecipeDefinitionsListDtos.cs | 48 +++++++++++ .../Crafting/RecipeDefinitionsWorldApi.cs | 54 +++++++++++++ server/NeonSprawl.Server/Program.cs | 1 + 7 files changed, 279 insertions(+) create mode 100644 bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru create mode 100644 bruno/neon-sprawl-server/recipe-definitions/folder.bru create mode 100644 docs/manual-qa/NEO-68.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsListDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs diff --git a/bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru b/bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru new file mode 100644 index 0000000..6a48dfb --- /dev/null +++ b/bruno/neon-sprawl-server/recipe-definitions/Get recipe definitions.bru @@ -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 }]); + }); +} diff --git a/bruno/neon-sprawl-server/recipe-definitions/folder.bru b/bruno/neon-sprawl-server/recipe-definitions/folder.bru new file mode 100644 index 0000000..73f8c35 --- /dev/null +++ b/bruno/neon-sprawl-server/recipe-definitions/folder.bru @@ -0,0 +1,3 @@ +meta { + name: recipe-definitions +} diff --git a/docs/manual-qa/NEO-68.md b/docs/manual-qa/NEO-68.md new file mode 100644 index 0000000..d036b35 --- /dev/null +++ b/docs/manual-qa/NEO-68.md @@ -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`). diff --git a/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs new file mode 100644 index 0000000..6ecb771 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Crafting/RecipeDefinitionsWorldApiTests.cs @@ -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 +{ + /// Frozen prototype eight in registry id order (ordinal). Keep in sync with manual QA and Bruno. + 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(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsListDtos.cs new file mode 100644 index 0000000..ebd8c02 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsListDtos.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Crafting; + +/// JSON body for GET /game/world/recipe-definitions (NEO-68). +public sealed class RecipeDefinitionsListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Loaded recipes ordered by stable id (ordinal), matching . + [JsonPropertyName("recipes")] + public required IReadOnlyList Recipes { get; init; } +} + +/// One row in the read-only recipe definition projection. +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 Inputs { get; init; } + + [JsonPropertyName("outputs")] + public required IReadOnlyList Outputs { get; init; } +} + +/// One input or output row in the read-only recipe definition projection. +public sealed class RecipeIoJson +{ + [JsonPropertyName("itemId")] + public required string ItemId { get; init; } + + [JsonPropertyName("quantity")] + public required int Quantity { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs new file mode 100644 index 0000000..a6d6a64 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Crafting/RecipeDefinitionsWorldApi.cs @@ -0,0 +1,54 @@ +namespace NeonSprawl.Server.Game.Crafting; + +/// Maps GET /game/world/recipe-definitions (NEO-68). +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(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 MapIoRows(IReadOnlyList rows) + { + var list = new List(rows.Count); + foreach (var row in rows) + { + list.Add( + new RecipeIoJson + { + ItemId = row.ItemId, + Quantity = row.Quantity, + }); + } + + return list; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 2f54b74..7d7e2e7 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -44,6 +44,7 @@ app.MapInteractionApi(); app.MapInteractablesWorldApi(); app.MapSkillDefinitionsWorldApi(); app.MapItemDefinitionsWorldApi(); +app.MapRecipeDefinitionsWorldApi(); app.MapResourceNodeDefinitionsWorldApi(); app.MapPlayerInventoryApi(); app.MapSkillProgressionSnapshotApi();