NEO-36: expose GET /game/world/skill-definitions with tests, Bruno, QA doc

pull/67/head
VinPropane 2026-05-04 20:36:06 -04:00
parent 044b4f823c
commit ab597ff350
8 changed files with 173 additions and 2 deletions

View File

@ -0,0 +1,46 @@
meta {
name: GET skill definitions
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/world/skill-definitions
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and skills 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.skills).to.be.an("array");
expect(body.skills.length).to.equal(3);
});
test("skills are ascending by id (ordinal)", function () {
const body = res.getBody();
const ids = body.skills.map((x) => x.id);
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
expect(ids).to.eql(sorted);
});
test("frozen prototype trio is present", function () {
const body = res.getBody();
const ids = new Set(body.skills.map((x) => x.id));
expect(ids.has("salvage")).to.equal(true);
expect(ids.has("refine")).to.equal(true);
expect(ids.has("intrusion")).to.equal(true);
});
test("salvage row matches catalog", function () {
const body = res.getBody();
const row = body.skills.find((x) => x.id === "salvage");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Salvage");
expect(row.category).to.equal("gather");
expect(row.allowedXpSourceKinds).to.include.members(["activity", "mission_reward"]);
});
}

View File

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

View File

@ -0,0 +1,14 @@
# Manual QA — NEO-36 (read-only skill catalog HTTP)
## Preconditions
- Server built and configured with default `Content:SkillsDirectory` pointing at repo `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/skill-definitions`** — expect **200** and **`Content-Type`** containing **`application/json`**.
3. Parse JSON — expect **`schemaVersion`** === **1**, **`skills`** array length **3**.
4. Confirm **`id`** values include **`intrusion`**, **`refine`**, **`salvage`** (frozen prototype trio).
5. Spot-check **`salvage`**: **`displayName`** “Salvage”, **`category`** **`gather`**, **`allowedXpSourceKinds`** includes **`activity`** and **`mission_reward`**.
6. Optional: run **`bruno/neon-sprawl-server/skill-definitions/Get skill definitions.bru`** against the same **`baseUrl`** (see `environments/Local.bru`).

View File

@ -35,8 +35,8 @@ No questions were put to the user during kickoff.
## Acceptance criteria checklist
- [ ] Bruno (and/or automated test) can call the endpoint against a running dev server and observe the **three frozen ids** (`salvage`, `refine`, `intrusion`).
- [ ] `docs/manual-qa/NEO-36.md` exists with a short checklist for hitting the endpoint.
- [x] Bruno (and/or automated test) can call the endpoint against a running dev server and observe the **three frozen ids** (`salvage`, `refine`, `intrusion`).
- [x] `docs/manual-qa/NEO-36.md` exists with a short checklist for hitting the endpoint.
## Technical approach

View File

@ -0,0 +1,39 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public class SkillDefinitionsWorldApiTests
{
[Fact]
public async Task GetSkillDefinitions_ShouldReturnSchemaV1_WithFrozenTrioInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/skill-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<SkillDefinitionsListResponse>();
Assert.NotNull(body);
Assert.Equal(SkillDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Skills);
var ids = body.Skills.Select(static s => s.Id).ToList();
Assert.Equal(new[] { "intrusion", "refine", "salvage" }, ids);
var salvage = body.Skills.Single(s => s.Id == "salvage");
Assert.Equal("Salvage", salvage.DisplayName);
Assert.Equal("gather", salvage.Category);
Assert.Equal(new[] { "activity", "mission_reward" }, salvage.AllowedXpSourceKinds);
var refine = body.Skills.Single(s => s.Id == "refine");
Assert.Contains("trainer", refine.AllowedXpSourceKinds);
var intrusion = body.Skills.Single(s => s.Id == "intrusion");
Assert.Contains("book_or_item", intrusion.AllowedXpSourceKinds);
}
}

View File

@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Skills;
/// <summary>JSON body for <c>GET /game/world/skill-definitions</c> (NEO-36).</summary>
public sealed class SkillDefinitionsListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Loaded skills ordered by stable <c>id</c> (ordinal), matching <see cref="ISkillDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
[JsonPropertyName("skills")]
public required IReadOnlyList<SkillDefinitionJson> Skills { get; init; }
}
/// <summary>One row in the read-only skill definition projection.</summary>
public sealed class SkillDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("category")]
public required string Category { get; init; }
[JsonPropertyName("allowedXpSourceKinds")]
public required IReadOnlyList<string> AllowedXpSourceKinds { get; init; }
}

View File

@ -0,0 +1,36 @@
namespace NeonSprawl.Server.Game.Skills;
/// <summary>Maps <c>GET /game/world/skill-definitions</c> (NEO-36).</summary>
public static class SkillDefinitionsWorldApi
{
public static WebApplication MapSkillDefinitionsWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/skill-definitions",
(ISkillDefinitionRegistry registry) =>
{
var defs = registry.GetDefinitionsInIdOrder();
var skills = new List<SkillDefinitionJson>(defs.Count);
foreach (var d in defs)
{
skills.Add(
new SkillDefinitionJson
{
Id = d.Id,
DisplayName = d.DisplayName,
Category = d.Category,
AllowedXpSourceKinds = d.AllowedXpSourceKinds,
});
}
return Results.Json(
new SkillDefinitionsListResponse
{
SchemaVersion = SkillDefinitionsListResponse.CurrentSchemaVersion,
Skills = skills,
});
});
return app;
}
}

View File

@ -27,6 +27,7 @@ app.MapGet("/health", () => Results.Json(new
app.MapPositionStateApi();
app.MapInteractionApi();
app.MapInteractablesWorldApi();
app.MapSkillDefinitionsWorldApi();
app.MapTargetingApi();
app.MapHotbarLoadoutApi();
app.MapCooldownSnapshotApi();