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.
pull/102/head
VinPropane 2026-05-24 17:08:15 -04:00
parent 8eccb14898
commit 1636fdc2cb
7 changed files with 279 additions and 0 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

@ -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

@ -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();