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
parent
8eccb14898
commit
1636fdc2cb
|
|
@ -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 }]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: recipe-definitions
|
||||
}
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ app.MapInteractionApi();
|
|||
app.MapInteractablesWorldApi();
|
||||
app.MapSkillDefinitionsWorldApi();
|
||||
app.MapItemDefinitionsWorldApi();
|
||||
app.MapRecipeDefinitionsWorldApi();
|
||||
app.MapResourceNodeDefinitionsWorldApi();
|
||||
app.MapPlayerInventoryApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
|
|
|
|||
Loading…
Reference in New Issue