From ab597ff350caf8f8dd812c32ea19d3aac4ee014c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Mon, 4 May 2026 20:36:06 -0400 Subject: [PATCH] NEO-36: expose GET /game/world/skill-definitions with tests, Bruno, QA doc --- .../Get skill definitions.bru | 46 +++++++++++++++++++ .../skill-definitions/folder.bru | 3 ++ docs/manual-qa/NEO-36.md | 14 ++++++ docs/plans/NEO-36-implementation-plan.md | 4 +- .../Skills/SkillDefinitionsWorldApiTests.cs | 39 ++++++++++++++++ .../Game/Skills/SkillDefinitionsListDtos.cs | 32 +++++++++++++ .../Game/Skills/SkillDefinitionsWorldApi.cs | 36 +++++++++++++++ server/NeonSprawl.Server/Program.cs | 1 + 8 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 bruno/neon-sprawl-server/skill-definitions/Get skill definitions.bru create mode 100644 bruno/neon-sprawl-server/skill-definitions/folder.bru create mode 100644 docs/manual-qa/NEO-36.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionsWorldApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillDefinitionsListDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillDefinitionsWorldApi.cs diff --git a/bruno/neon-sprawl-server/skill-definitions/Get skill definitions.bru b/bruno/neon-sprawl-server/skill-definitions/Get skill definitions.bru new file mode 100644 index 0000000..d4cf25e --- /dev/null +++ b/bruno/neon-sprawl-server/skill-definitions/Get skill definitions.bru @@ -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"]); + }); +} diff --git a/bruno/neon-sprawl-server/skill-definitions/folder.bru b/bruno/neon-sprawl-server/skill-definitions/folder.bru new file mode 100644 index 0000000..6813433 --- /dev/null +++ b/bruno/neon-sprawl-server/skill-definitions/folder.bru @@ -0,0 +1,3 @@ +meta { + name: skill-definitions +} diff --git a/docs/manual-qa/NEO-36.md b/docs/manual-qa/NEO-36.md new file mode 100644 index 0000000..0b8f8db --- /dev/null +++ b/docs/manual-qa/NEO-36.md @@ -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`). diff --git a/docs/plans/NEO-36-implementation-plan.md b/docs/plans/NEO-36-implementation-plan.md index 804c95b..df07127 100644 --- a/docs/plans/NEO-36-implementation-plan.md +++ b/docs/plans/NEO-36-implementation-plan.md @@ -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 diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionsWorldApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionsWorldApiTests.cs new file mode 100644 index 0000000..7a2b25f --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionsWorldApiTests.cs @@ -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(); + 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); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionsListDtos.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionsListDtos.cs new file mode 100644 index 0000000..7531c6a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionsListDtos.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Skills; + +/// JSON body for GET /game/world/skill-definitions (NEO-36). +public sealed class SkillDefinitionsListResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + /// Loaded skills ordered by stable id (ordinal), matching . + [JsonPropertyName("skills")] + public required IReadOnlyList Skills { get; init; } +} + +/// One row in the read-only skill definition projection. +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 AllowedXpSourceKinds { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillDefinitionsWorldApi.cs b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionsWorldApi.cs new file mode 100644 index 0000000..1658417 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillDefinitionsWorldApi.cs @@ -0,0 +1,36 @@ +namespace NeonSprawl.Server.Game.Skills; + +/// Maps GET /game/world/skill-definitions (NEO-36). +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(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; + } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 3a645f9..94ed641 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -27,6 +27,7 @@ app.MapGet("/health", () => Results.Json(new app.MapPositionStateApi(); app.MapInteractionApi(); app.MapInteractablesWorldApi(); +app.MapSkillDefinitionsWorldApi(); app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi();