NEO-37: add skill progression snapshot GET API, tests, Bruno, docs
parent
413484000f
commit
9cd96a72c3
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: skill-progression
|
||||
}
|
||||
|
|
@ -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}}`**).
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SkillProgressionSnapshotResponse>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using NeonSprawl.Server.Game.PositionState;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Maps <c>GET /game/players/{{id}}/skill-progression</c> (NEO-37).</summary>
|
||||
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<SkillProgressionRowJson>(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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>JSON body for <c>GET /game/players/{{id}}/skill-progression</c> (NEO-37).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>One row per registered skill, ordered by stable <c>id</c> (ordinal).</summary>
|
||||
[JsonPropertyName("skills")]
|
||||
public required IReadOnlyList<SkillProgressionRowJson> Skills { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>XP and level for one skill before grants (defaults) or after NEO-38 persistence.</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ app.MapPositionStateApi();
|
|||
app.MapInteractionApi();
|
||||
app.MapInteractablesWorldApi();
|
||||
app.MapSkillDefinitionsWorldApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapTargetingApi();
|
||||
app.MapHotbarLoadoutApi();
|
||||
app.MapCooldownSnapshotApi();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue