From 9cd96a72c3caec01930dab1ab8a1434698ddd4c0 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 6 May 2026 21:48:17 -0400 Subject: [PATCH] NEO-37: add skill progression snapshot GET API, tests, Bruno, docs --- .../Get skill progression.bru | 46 ++++++++++++++++ .../skill-progression/folder.bru | 3 ++ docs/manual-qa/NEO-37.md | 27 ++++++++++ docs/plans/NEO-37-implementation-plan.md | 8 +-- .../SkillProgressionSnapshotApiTests.cs | 52 +++++++++++++++++++ .../Skills/SkillProgressionSnapshotApi.cs | 50 ++++++++++++++++++ .../Skills/SkillProgressionSnapshotDtos.cs | 32 ++++++++++++ server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 8 +++ 9 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 bruno/neon-sprawl-server/skill-progression/Get skill progression.bru create mode 100644 bruno/neon-sprawl-server/skill-progression/folder.bru create mode 100644 docs/manual-qa/NEO-37.md create mode 100644 server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs create mode 100644 server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotDtos.cs diff --git a/bruno/neon-sprawl-server/skill-progression/Get skill progression.bru b/bruno/neon-sprawl-server/skill-progression/Get skill progression.bru new file mode 100644 index 0000000..f6fec84 --- /dev/null +++ b/bruno/neon-sprawl-server/skill-progression/Get skill progression.bru @@ -0,0 +1,46 @@ +meta { + name: GET skill progression + type: http + seq: 1 +} + +docs { + NEO-37: per-player read model; join with GET /game/world/skill-definitions for display names. +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/skill-progression + 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.playerId).to.equal("dev-local-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 < b ? -1 : a > b ? 1 : 0)); + expect(ids).to.eql(sorted); + }); + + test("frozen prototype trio with default xp and level", function () { + const body = res.getBody(); + const intrusion = body.skills.find((x) => x.id === "intrusion"); + expect(intrusion).to.be.an("object"); + expect(intrusion.xp).to.equal(0); + expect(intrusion.level).to.equal(1); + 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); + }); +} diff --git a/bruno/neon-sprawl-server/skill-progression/folder.bru b/bruno/neon-sprawl-server/skill-progression/folder.bru new file mode 100644 index 0000000..0de69bd --- /dev/null +++ b/bruno/neon-sprawl-server/skill-progression/folder.bru @@ -0,0 +1,3 @@ +meta { + name: skill-progression +} diff --git a/docs/manual-qa/NEO-37.md b/docs/manual-qa/NEO-37.md new file mode 100644 index 0000000..5e668f5 --- /dev/null +++ b/docs/manual-qa/NEO-37.md @@ -0,0 +1,27 @@ +# NEO-37 — Manual QA checklist + +| Field | Value | +|-------|-------| +| Key | NEO-37 | +| Title | Skill progression snapshot (read model) | +| Linear | https://linear.app/neon-sprawl/issue/NEO-37/skill-progression-snapshot-read-model | +| Plan | `docs/plans/NEO-37-implementation-plan.md` | +| Branch | `NEO-37-skill-progression-snapshot-read-model` | + +## Preconditions + +- Server running with default dev player seeded (same as other **`/game/players/dev-local-1/...`** flows). +- Skill catalog loaded (prototype trio); progression rows mirror registry order. + +## Checklist + +1. Start **`NeonSprawl.Server`** (e.g. `dotnet run` from `server/NeonSprawl.Server`). +2. **`GET /game/players/dev-local-1/skill-progression`** — expect **200** and **`Content-Type`** containing **`application/json`**. + + ```bash + curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression" + ``` + +3. Parse JSON — **`schemaVersion`** **1**, **`playerId`** **`dev-local-1`**, **`skills`** length **3**, **`id`** order **`intrusion`**, **`refine`**, **`salvage`**; each row **`xp`** **0**, **`level`** **1**. +4. Unknown player — **`GET /game/players/missing-player/skill-progression`** — expect **404**. +5. Optional: run **`bruno/neon-sprawl-server/skill-progression/Get skill progression.bru`** (see `environments/Local.bru` for **`{{baseUrl}}`**). diff --git a/docs/plans/NEO-37-implementation-plan.md b/docs/plans/NEO-37-implementation-plan.md index c0cd757..6d79189 100644 --- a/docs/plans/NEO-37-implementation-plan.md +++ b/docs/plans/NEO-37-implementation-plan.md @@ -34,10 +34,10 @@ ## Acceptance criteria checklist -- [ ] Versioned JSON response for **`GET /game/players/{id}/skill-progression`** (`schemaVersion` **1**). -- [ ] One row per registered skill id; **`xp`** = **0**, **`level`** = **1** for all rows in this story; **`skills`** ordered by **`ISkillDefinitionRegistry.GetDefinitionsInIdOrder()`**. -- [ ] Automated tests (AAA); Bruno folder mirroring NEO-36 (`skill-definitions` conventions). -- [ ] **`server/README.md`** section documenting the endpoint (curl + pointers to plan / manual QA / Bruno). +- [x] Versioned JSON response for **`GET /game/players/{id}/skill-progression`** (`schemaVersion` **1**). +- [x] One row per registered skill id; **`xp`** = **0**, **`level`** = **1** for all rows in this story; **`skills`** ordered by **`ISkillDefinitionRegistry.GetDefinitionsInIdOrder()`**. +- [x] Automated tests (AAA); Bruno folder mirroring NEO-36 (`skill-definitions` conventions). +- [x] **`server/README.md`** section documenting the endpoint (curl + pointers to plan / manual QA / Bruno). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs new file mode 100644 index 0000000..217c9eb --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Skills/SkillProgressionSnapshotApiTests.cs @@ -0,0 +1,52 @@ +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 sealed class SkillProgressionSnapshotApiTests +{ + [Fact] + public async Task GetSkillProgression_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetSkillProgression_ShouldReturnSchemaV1_WithDefaultXpAndLevel_ForFrozenTrioInIdOrder() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/dev-local-1/skill-progression"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(SkillProgressionSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + Assert.NotNull(body.Skills); + var ids = body.Skills.Select(static s => s.Id).ToList(); + Assert.Equal(new[] { "intrusion", "refine", "salvage" }, ids); + Assert.All( + body.Skills, + s => + { + Assert.Equal(0, s.Xp); + Assert.Equal(1, s.Level); + }); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs new file mode 100644 index 0000000..d1dd05a --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs @@ -0,0 +1,50 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Skills; + +/// Maps GET /game/players/{{id}}/skill-progression (NEO-37). +public static class SkillProgressionSnapshotApi +{ + public static WebApplication MapSkillProgressionSnapshotApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/skill-progression", + (string id, IPositionStateStore positions, ISkillDefinitionRegistry registry) => + { + if (!positions.TryGetPosition(id, out _)) + { + return Results.NotFound(); + } + + var trimmedId = id.Trim(); + return Results.Json(BuildSnapshot(trimmedId, registry)); + }); + + return app; + } + + internal static SkillProgressionSnapshotResponse BuildSnapshot( + string playerId, + ISkillDefinitionRegistry registry) + { + var defs = registry.GetDefinitionsInIdOrder(); + var skills = new List(defs.Count); + foreach (var d in defs) + { + skills.Add( + new SkillProgressionRowJson + { + Id = d.Id, + Xp = 0, + Level = 1, + }); + } + + return new SkillProgressionSnapshotResponse + { + PlayerId = playerId, + Skills = skills, + SchemaVersion = SkillProgressionSnapshotResponse.CurrentSchemaVersion, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotDtos.cs b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotDtos.cs new file mode 100644 index 0000000..e099ec7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotDtos.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Skills; + +/// JSON body for GET /game/players/{{id}}/skill-progression (NEO-37). +public sealed class SkillProgressionSnapshotResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// One row per registered skill, ordered by stable id (ordinal). + [JsonPropertyName("skills")] + public required IReadOnlyList Skills { get; init; } +} + +/// XP and level for one skill before grants (defaults) or after NEO-38 persistence. +public sealed class SkillProgressionRowJson +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("xp")] + public int Xp { get; init; } + + [JsonPropertyName("level")] + public int Level { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 94ed641..b9241ec 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -28,6 +28,7 @@ app.MapPositionStateApi(); app.MapInteractionApi(); app.MapInteractablesWorldApi(); app.MapSkillDefinitionsWorldApi(); +app.MapSkillProgressionSnapshotApi(); app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi(); diff --git a/server/README.md b/server/README.md index 91d27a0..1005545 100644 --- a/server/README.md +++ b/server/README.md @@ -45,6 +45,14 @@ On success, **Information** logs include the resolved skills directory path and curl -sS -i "http://localhost:5253/game/world/skill-definitions" ``` +## Skill progression snapshot (NEO-37) + +**`GET /game/players/{id}/skill-progression`** returns a versioned JSON body (`schemaVersion` **1**, **`playerId`**, **`skills`**) with one row per registered skill (**`id`**, **`xp`**, **`level`**). Rows are ordered like **`ISkillDefinitionRegistry.GetDefinitionsInIdOrder()`**; before XP grants (**NEO-38**) each row boots with **`xp`** **0** and **`level`** **1**. Unknown players (**no row in position state**) receive **404**, same gate as **`GET …/cooldown-snapshot`**. Plan: [NEO-37 implementation plan](../../docs/plans/NEO-37-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-37.md`](../../docs/manual-qa/NEO-37.md); Bruno: `bruno/neon-sprawl-server/skill-progression/`. + +```bash +curl -sS -i "http://localhost:5253/game/players/dev-local-1/skill-progression" +``` + ## Position persistence (NEO-8) When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).