NEO-48: address r2 review — atomic fixture, tests, docs
Batch skillXp then resetPerkState with XP rollback; 400 for invalid skillXp; MasteryFixtureApi gate tests; README fixture vs grant semantics; Bruno GET playerId.pull/83/head
parent
2bb02d76e9
commit
aba26446d3
|
|
@ -10,7 +10,7 @@ docs {
|
||||||
}
|
}
|
||||||
|
|
||||||
get {
|
get {
|
||||||
url: {{baseUrl}}/game/players/dev-local-1/perk-state
|
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
|
||||||
body: none
|
body: none
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ tests {
|
||||||
expect(res.getHeader("content-type")).to.contain("application/json");
|
expect(res.getHeader("content-type")).to.contain("application/json");
|
||||||
const body = res.getBody();
|
const body = res.getBody();
|
||||||
expect(body.schemaVersion).to.equal(1);
|
expect(body.schemaVersion).to.equal(1);
|
||||||
expect(body.playerId).to.equal("dev-local-1");
|
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
|
||||||
expect(body.branchPicks).to.be.an("array");
|
expect(body.branchPicks).to.be.an("array");
|
||||||
expect(body.unlockedPerkIds).to.be.an("array");
|
expect(body.unlockedPerkIds).to.be.an("array");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-48 perk-state HTTP smoke. Deny requests call POST …/__dev/mastery-fixture in pre-request to set salvage XP and clear perk state (Development / Game:EnableMasteryFixtureApi).
|
NEO-48 perk-state HTTP smoke. Deny requests call POST …/__dev/mastery-fixture in pre-request to set salvage XP and clear perk state (Development / Game:EnableMasteryFixtureApi).
|
||||||
|
Run deny requests (seq 3–4) before happy-path POST tier1 if you need a clean dev-local-1; deny pre-requests reset shared player state.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,9 @@
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `server/NeonSprawl.Server/Game/Mastery/PerkStateDtos.cs` | `PerkStateSnapshotResponse`, `PerkBranchPickTrackJson`, `PerkBranchPickRowJson`, `PerkBranchSelectRequest`, `PerkBranchSelectResponse`, `PerkUnlockedEventJson`. |
|
| `server/NeonSprawl.Server/Game/Mastery/PerkStateDtos.cs` | `PerkStateSnapshotResponse`, `PerkBranchPickTrackJson`, `PerkBranchPickRowJson`, `PerkBranchSelectRequest`, `PerkBranchSelectResponse`, `PerkUnlockedEventJson`. |
|
||||||
| `server/NeonSprawl.Server/Game/Mastery/PerkStateApi.cs` | `MapPerkStateApi`, `BuildSnapshot`, GET/POST handlers. |
|
| `server/NeonSprawl.Server/Game/Mastery/PerkStateApi.cs` | `MapPerkStateApi`, `BuildSnapshot`, GET/POST handlers. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs` | Dev fixture request/response (`resetPerkState`, `skillXp`). |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs` | `POST …/__dev/mastery-fixture` (gated). |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs` | Batch XP apply + perk reset with XP rollback on reset failure. |
|
||||||
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs` | In-memory host: GET empty defaults; POST tier-1 happy after XP grant; deny `level_too_low` / `branch_already_chosen`; GET reflects POST; **404** / **400** gates. |
|
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs` | In-memory host: GET empty defaults; POST tier-1 happy after XP grant; deny `level_too_low` / `branch_already_chosen`; GET reflects POST; **404** / **400** gates. |
|
||||||
| `bruno/neon-sprawl-server/perk-state/folder.bru` | Collection folder metadata. |
|
| `bruno/neon-sprawl-server/perk-state/folder.bru` | Collection folder metadata. |
|
||||||
| `bruno/neon-sprawl-server/perk-state/Get perk state.bru` | GET smoke + schema assertions. |
|
| `bruno/neon-sprawl-server/perk-state/Get perk state.bru` | GET smoke + schema assertions. |
|
||||||
|
|
@ -94,7 +97,13 @@
|
||||||
|
|
||||||
| Path | Rationale |
|
| Path | Rationale |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| `server/NeonSprawl.Server/Program.cs` | Register **`MapPerkStateApi()`**. |
|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapPerkStateApi()`** and conditional **`MapMasteryFixtureApi()`**. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs` | **`TrySetSkillXpTotal`** / **`TrySetSkillXpTotals`** for dev fixture. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs` | **`TryResetPerkState`** for dev fixture. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs` | Fixture XP set implementations. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs` | Fixture XP set implementations. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs` | **`TryResetPerkState`**. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs` | **`TryResetPerkState`**. |
|
||||||
| `server/README.md` | New **Perk state (NEO-48)** section: GET/POST paths, schema v1, `selected`/`reasonCode`/`unlockedEvents`, reason code table (link `PerkUnlockReasonCodes`), Bruno path, manual QA link. |
|
| `server/README.md` | New **Perk state (NEO-48)** section: GET/POST paths, schema v1, `selected`/`reasonCode`/`unlockedEvents`, reason code table (link `PerkUnlockReasonCodes`), Bruno path, manual QA link. |
|
||||||
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Note NEO-48 HTTP landed (replace “HTTP is NEO-48” pending wording in NEO-47 handoff). |
|
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Note NEO-48 HTTP landed (replace “HTTP is NEO-48” pending wording in NEO-47 handoff). |
|
||||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: NEO-48 perk-state HTTP. |
|
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: NEO-48 perk-state HTTP. |
|
||||||
|
|
@ -105,6 +114,7 @@
|
||||||
| Test file | What it covers |
|
| Test file | What it covers |
|
||||||
|-----------|----------------|
|
|-----------|----------------|
|
||||||
| `PerkStateApiTests.cs` | **AAA** integration via `InMemoryWebApplicationFactory`: **GET** returns v1 empty state for dev player; **POST** after `POST …/skill-progression` grant (100 XP → level 2) selects `scrap_efficiency` tier 1 → `selected: true`, pick in **GET**; **POST** deny `level_too_low` without grant; **POST** deny `branch_already_chosen` on second pick; **404** unknown player; **400** bad `schemaVersion`. Optional: **POST** at level 4+ includes non-empty **`unlockedEvents`** when tier-2 perk unlocks on same request (path-auto). |
|
| `PerkStateApiTests.cs` | **AAA** integration via `InMemoryWebApplicationFactory`: **GET** returns v1 empty state for dev player; **POST** after `POST …/skill-progression` grant (100 XP → level 2) selects `scrap_efficiency` tier 1 → `selected: true`, pick in **GET**; **POST** deny `level_too_low` without grant; **POST** deny `branch_already_chosen` on second pick; **404** unknown player; **400** bad `schemaVersion`. Optional: **POST** at level 4+ includes non-empty **`unlockedEvents`** when tier-2 perk unlocks on same request (path-auto). |
|
||||||
|
| `MasteryFixtureApiTests.cs` | Fixture route disabled → **404**; **400** null body / bad schema / negative XP; **404** unknown player; reset after progressed state → **`level_too_low`** on next branch POST. |
|
||||||
| Existing `PerkUnlockEngineTests.cs` | No changes required — engine rules remain covered; API tests are thin HTTP mapping. |
|
| Existing `PerkUnlockEngineTests.cs` | No changes required — engine rules remain covered; API tests are thin HTTP mapping. |
|
||||||
|
|
||||||
Bruno + **`docs/manual-qa/NEO-48.md`** supplement automated coverage. Postgres persistence of picks is already covered by **`PerkStatePersistenceIntegrationTests`** (NEO-47); no new Postgres API test unless a regression gap appears during implementation.
|
Bruno + **`docs/manual-qa/NEO-48.md`** supplement automated coverage. Postgres persistence of picks is already covered by **`PerkStatePersistenceIntegrationTests`** (NEO-47); no new Postgres API test unless a regression gap appears during implementation.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
**Scope:** Branch `NEO-48-perk-state-get-branch-selection-post-bruno` · commits `3ba8300`–`ffdbed2` (delta since [first review](2026-05-17-NEO-48.md)) · full branch vs `main` for merge readiness
|
**Scope:** Branch `NEO-48-perk-state-get-branch-selection-post-bruno` · commits `3ba8300`–`ffdbed2` (delta since [first review](2026-05-17-NEO-48.md)) · full branch vs `main` for merge readiness
|
||||||
**Base:** `main`
|
**Base:** `main`
|
||||||
**Prior review:** [`2026-05-17-NEO-48.md`](2026-05-17-NEO-48.md) (perk-state HTTP + initial Bruno/tests)
|
**Prior review:** [`2026-05-17-NEO-48.md`](2026-05-17-NEO-48.md) (perk-state HTTP + initial Bruno/tests)
|
||||||
|
**Follow-up:** R2 suggestions 1–3 and nits 1 + 3 addressed in a follow-up commit on the same branch.
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
|
|
@ -17,7 +18,7 @@ Follow-up work closes the first-review Bruno/test gaps and replaces a short-live
|
||||||
|
|
||||||
| Document | Result |
|
| Document | Result |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| [`docs/plans/NEO-48-implementation-plan.md`](../plans/NEO-48-implementation-plan.md) | **Partially matches** — perk-state HTTP/Bruno acceptance unchanged; kickoff table documents **`__dev/mastery-fixture`** for Bruno deny setup. **Files to add/modify** tables do not list `MasteryFixture*.cs` or store `TryReset*` / `TrySetSkillXpTotal` (implementation detail worth a one-line plan add). |
|
| [`docs/plans/NEO-48-implementation-plan.md`](../plans/NEO-48-implementation-plan.md) | **Matches** — perk-state HTTP/Bruno acceptance unchanged; kickoff documents fixture API; **Files to add/modify** lists `MasteryFixture*` and store fixture methods. |
|
||||||
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-48 handoff covers HTTP only; dev fixture correctly omitted from module contracts. |
|
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-48 handoff covers HTTP only; dev fixture correctly omitted from module contracts. |
|
||||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — production perk rules still flow through `PerkUnlockEngine`; fixture bypasses grant path by design (dev/QA only). |
|
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — production perk rules still flow through `PerkUnlockEngine`; fixture bypasses grant path by design (dev/QA only). |
|
||||||
| [`docs/manual-qa/NEO-48.md`](../manual-qa/NEO-48.md) | **Matches** — fixture curl examples + Bruno deny checklist updated. |
|
| [`docs/manual-qa/NEO-48.md`](../manual-qa/NEO-48.md) | **Matches** — fixture curl examples + Bruno deny checklist updated. |
|
||||||
|
|
@ -30,16 +31,16 @@ None.
|
||||||
|
|
||||||
## Suggestions
|
## Suggestions
|
||||||
|
|
||||||
1. **Atomic fixture apply** — `MasteryFixtureOperations.TryApply` can leave mixed state if `resetPerkState` succeeds and a later `skillXp` entry fails (or an earlier XP write succeeds before a later one fails). For a dev endpoint this is unlikely with Bruno’s fixed payloads, but consider applying XP first then reset, a single transaction on Postgres, or returning **409**/**500** with a note when partial apply is detected.
|
1. ~~**Atomic fixture apply** — `MasteryFixtureOperations.TryApply` can leave mixed state if `resetPerkState` succeeds and a later `skillXp` entry fails (or an earlier XP write succeeds before a later one fails). For a dev endpoint this is unlikely with Bruno’s fixed payloads, but consider applying XP first then reset, a single transaction on Postgres, or returning **409**/**500** with a note when partial apply is detected.~~ **Done.** Batch **`TrySetSkillXpTotals`** then **`resetPerkState`**; XP rollback via previous snapshot if reset fails; **400** for invalid `skillXp` before writes.
|
||||||
2. **Fixture API test coverage** — Add small tests for **400** (null body / bad `schemaVersion`) and **404** (unknown player, negative XP in `skillXp`) on `__dev/mastery-fixture` to mirror perk-state gate tests.
|
2. ~~**Fixture API test coverage** — Add small tests for **400** (null body / bad `schemaVersion`) and **404** (unknown player, negative XP in `skillXp`) on `__dev/mastery-fixture` to mirror perk-state gate tests.~~ **Done.** Four new tests in `MasteryFixtureApiTests`.
|
||||||
3. **Document fixture vs grant semantics** — README could note that **`skillXp` on the fixture sets absolute totals without running `SkillProgressionGrantOperations` / perk level-up reevaluation** (intentional for deny setup; use normal grant API when testing unlock side effects).
|
3. ~~**Document fixture vs grant semantics** — README could note that **`skillXp` on the fixture sets absolute totals without running `SkillProgressionGrantOperations` / perk level-up reevaluation** (intentional for deny setup; use normal grant API when testing unlock side effects).~~ **Done.** README Perk state section.
|
||||||
|
|
||||||
## Nits
|
## Nits
|
||||||
|
|
||||||
- Nit: `Get perk state.bru` still asserts `playerId === "dev-local-1"` while deny requests use `{{playerId}}` from the environment—harmless if `Local.bru` keeps the default; align assertions for copy-paste environments.
|
- ~~Nit: `Get perk state.bru` still asserts `playerId === "dev-local-1"` while deny requests use `{{playerId}}` from the environment—harmless if `Local.bru` keeps the default; align assertions for copy-paste environments.~~ **Done.** GET uses `{{playerId}}` in URL and assertion.
|
||||||
- Nit: `TryResetPerkState` / `TrySetSkillXpTotal` live on production store interfaces—acceptable for prototype; if the surface grows, consider a dev-only `IMasteryFixtureStore` facade later.
|
- Nit: `TryResetPerkState` / `TrySetSkillXpTotal` live on production store interfaces—acceptable for prototype; if the surface grows, consider a dev-only `IMasteryFixtureStore` facade later.
|
||||||
- Nit: Bruno deny flows mutate shared `dev-local-1`; folder `docs` explain fixture pre-requests—run deny requests before happy-path smokes or accept order sensitivity in manual runs.
|
- ~~Nit: Bruno deny flows mutate shared `dev-local-1`; folder `docs` explain fixture pre-requests—run deny requests before happy-path smokes or accept order sensitivity in manual runs.~~ **Done.** Folder `docs` note on run order.
|
||||||
- Nit: `MasteryFixtureApiTests.PostMasteryFixture_ShouldResetSalvageXpAndPerkState_ThenLevelTooLowDenies` performs reset and deny POST both under **Act**; valid for an integration scenario, but heavy **Arrange** (grant + pick) could move to a private helper like `PerkStateApiTests.GrantSalvageLevel2Async`.
|
- ~~Nit: `MasteryFixtureApiTests.PostMasteryFixture_ShouldResetSalvageXpAndPerkState_ThenLevelTooLowDenies` performs reset and deny POST both under **Act**; valid for an integration scenario, but heavy **Arrange** (grant + pick) could move to a private helper like `PerkStateApiTests.GrantSalvageLevel2Async`.~~ **Done.** `ArrangeSalvageProgressedWithTier1PickAsync`.
|
||||||
|
|
||||||
## Delta since first review (testing refactor)
|
## Delta since first review (testing refactor)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -11,11 +13,46 @@ namespace NeonSprawl.Server.Tests.Game.Mastery;
|
||||||
public sealed class MasteryFixtureApiTests
|
public sealed class MasteryFixtureApiTests
|
||||||
{
|
{
|
||||||
private const string DevPlayer = "dev-local-1";
|
private const string DevPlayer = "dev-local-1";
|
||||||
|
private const string FixturePath = $"/game/players/{DevPlayer}/__dev/mastery-fixture";
|
||||||
|
|
||||||
|
private static MasteryFixtureRequest ValidFixture(
|
||||||
|
bool resetPerkState = true,
|
||||||
|
Dictionary<string, int>? skillXp = null) =>
|
||||||
|
new MasteryFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion,
|
||||||
|
ResetPerkState = resetPerkState,
|
||||||
|
SkillXp = skillXp ?? new Dictionary<string, int> { ["salvage"] = 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static async Task ArrangeSalvageProgressedWithTier1PickAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
var grant = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{DevPlayer}/skill-progression",
|
||||||
|
new SkillProgressionGrantRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
|
||||||
|
SkillId = "salvage",
|
||||||
|
Amount = 100,
|
||||||
|
SourceKind = "activity",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
|
||||||
|
var pick = await client.PostAsJsonAsync(
|
||||||
|
$"/game/players/{DevPlayer}/perk-state",
|
||||||
|
new PerkBranchSelectRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion,
|
||||||
|
SkillId = "salvage",
|
||||||
|
TierIndex = 1,
|
||||||
|
BranchId = "scrap_efficiency",
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.OK, pick.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
|
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
|
||||||
{
|
{
|
||||||
// Arrange — Production + fixture flag off => endpoint not mapped
|
// Arrange
|
||||||
await using var factory = new InMemoryWebApplicationFactory();
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
var client = factory.WithWebHostBuilder(b =>
|
var client = factory.WithWebHostBuilder(b =>
|
||||||
{
|
{
|
||||||
|
|
@ -24,14 +61,76 @@ public sealed class MasteryFixtureApiTests
|
||||||
}).CreateClient();
|
}).CreateClient();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await client.PostAsJsonAsync(
|
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture());
|
||||||
$"/game/players/{DevPlayer}/__dev/mastery-fixture",
|
|
||||||
new MasteryFixtureRequest
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenBodyNull()
|
||||||
{
|
{
|
||||||
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion,
|
// Arrange
|
||||||
ResetPerkState = true,
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
SkillXp = new Dictionary<string, int> { ["salvage"] = 0 },
|
var client = factory.CreateClient();
|
||||||
});
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
FixturePath,
|
||||||
|
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var request = ValidFixture();
|
||||||
|
request = new MasteryFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion + 99,
|
||||||
|
ResetPerkState = request.ResetPerkState,
|
||||||
|
SkillXp = request.SkillXp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(FixturePath, request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenSkillXpNegative()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
ValidFixture(skillXp: new Dictionary<string, int> { ["salvage"] = -1 }));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenPlayerUnknown()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync(
|
||||||
|
"/game/players/missing-player/__dev/mastery-fixture",
|
||||||
|
ValidFixture());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
|
@ -43,34 +142,10 @@ public sealed class MasteryFixtureApiTests
|
||||||
// Arrange
|
// Arrange
|
||||||
await using var factory = new InMemoryWebApplicationFactory();
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
|
await ArrangeSalvageProgressedWithTier1PickAsync(client);
|
||||||
$"/game/players/{DevPlayer}/skill-progression",
|
|
||||||
new SkillProgressionGrantRequest
|
|
||||||
{
|
|
||||||
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
|
|
||||||
SkillId = "salvage",
|
|
||||||
Amount = 100,
|
|
||||||
SourceKind = "activity",
|
|
||||||
})).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
|
|
||||||
$"/game/players/{DevPlayer}/perk-state",
|
|
||||||
new PerkBranchSelectRequest
|
|
||||||
{
|
|
||||||
SchemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion,
|
|
||||||
SkillId = "salvage",
|
|
||||||
TierIndex = 1,
|
|
||||||
BranchId = "scrap_efficiency",
|
|
||||||
})).StatusCode);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var reset = await client.PostAsJsonAsync(
|
var reset = await client.PostAsJsonAsync(FixturePath, ValidFixture());
|
||||||
$"/game/players/{DevPlayer}/__dev/mastery-fixture",
|
|
||||||
new MasteryFixtureRequest
|
|
||||||
{
|
|
||||||
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion,
|
|
||||||
ResetPerkState = true,
|
|
||||||
SkillXp = new Dictionary<string, int> { ["salvage"] = 0 },
|
|
||||||
});
|
|
||||||
var deny = await client.PostAsJsonAsync(
|
var deny = await client.PostAsJsonAsync(
|
||||||
$"/game/players/{DevPlayer}/perk-state",
|
$"/game/players/{DevPlayer}/perk-state",
|
||||||
new PerkBranchSelectRequest
|
new PerkBranchSelectRequest
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ public static class MasteryFixtureApi
|
||||||
return Results.BadRequest();
|
return Results.BadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!MasteryFixtureOperations.TryNormalizeSkillXp(body.SkillXp, out _))
|
||||||
|
{
|
||||||
|
return Results.BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
var trimmedId = id.Trim();
|
var trimmedId = id.Trim();
|
||||||
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,35 +5,66 @@ namespace NeonSprawl.Server.Game.Mastery;
|
||||||
/// <summary>Applies dev-only mastery fixture resets (NEO-48).</summary>
|
/// <summary>Applies dev-only mastery fixture resets (NEO-48).</summary>
|
||||||
public static class MasteryFixtureOperations
|
public static class MasteryFixtureOperations
|
||||||
{
|
{
|
||||||
|
/// <summary>Validates <paramref name="skillXp"/> entries; returns false when any key is blank or XP is negative.</summary>
|
||||||
|
public static bool TryNormalizeSkillXp(
|
||||||
|
IReadOnlyDictionary<string, int>? skillXp,
|
||||||
|
out Dictionary<string, int> normalized)
|
||||||
|
{
|
||||||
|
normalized = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
if (skillXp is null || skillXp.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (skillId, xp) in skillXp)
|
||||||
|
{
|
||||||
|
var sid = skillId.Trim();
|
||||||
|
if (sid.Length == 0 || xp < 0)
|
||||||
|
{
|
||||||
|
normalized.Clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized[sid] = xp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Applies skill XP (batch, then perk reset). Rolls back XP when perk reset fails after XP writes.</summary>
|
||||||
public static bool TryApply(
|
public static bool TryApply(
|
||||||
string playerId,
|
string playerId,
|
||||||
MasteryFixtureRequest body,
|
MasteryFixtureRequest body,
|
||||||
IPlayerPerkStateStore perkStore,
|
IPlayerPerkStateStore perkStore,
|
||||||
IPlayerSkillProgressionStore xpStore)
|
IPlayerSkillProgressionStore xpStore)
|
||||||
{
|
{
|
||||||
if (body.ResetPerkState && !perkStore.TryResetPerkState(playerId))
|
if (!TryNormalizeSkillXp(body.SkillXp, out var skillXp))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.SkillXp is null || body.SkillXp.Count == 0)
|
var previousXp = new Dictionary<string, int>(xpStore.GetXpTotals(playerId), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (skillXp.Count > 0 && !xpStore.TrySetSkillXpTotals(playerId, skillXp))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ResetPerkState)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (skillId, xp) in body.SkillXp)
|
if (perkStore.TryResetPerkState(playerId))
|
||||||
{
|
{
|
||||||
if (skillId.Trim().Length == 0 || xp < 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!xpStore.TrySetSkillXpTotal(playerId, skillId, xp))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (skillXp.Count > 0)
|
||||||
|
{
|
||||||
|
_ = xpStore.TrySetSkillXpTotals(playerId, previousXp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,7 @@ public interface IPlayerSkillProgressionStore
|
||||||
|
|
||||||
/// <summary>Sets absolute XP for one skill (NEO-48 dev fixture API). <paramref name="xp"/> must be >= 0.</summary>
|
/// <summary>Sets absolute XP for one skill (NEO-48 dev fixture API). <paramref name="xp"/> must be >= 0.</summary>
|
||||||
bool TrySetSkillXpTotal(string playerId, string skillId, int xp);
|
bool TrySetSkillXpTotal(string playerId, string skillId, int xp);
|
||||||
|
|
||||||
|
/// <summary>Sets absolute XP for multiple skills atomically (NEO-48 dev fixture API).</summary>
|
||||||
|
bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,42 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions<GamePositionOpt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals)
|
||||||
|
{
|
||||||
|
var key = NormalizePlayerId(playerId);
|
||||||
|
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (skillId, xp) in skillXpTotals)
|
||||||
|
{
|
||||||
|
if (skillId.Trim().Length == 0 || xp < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
foreach (var (skillId, xp) in skillXpTotals)
|
||||||
|
{
|
||||||
|
var sid = skillId.Trim();
|
||||||
|
if (xp == 0)
|
||||||
|
{
|
||||||
|
inner.TryRemove(sid, out _);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inner[sid] = xp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizePlayerId(string? playerId)
|
private static string NormalizePlayerId(string? playerId)
|
||||||
{
|
{
|
||||||
var t = playerId?.Trim();
|
var t = playerId?.Trim();
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,70 @@ public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals)
|
||||||
|
{
|
||||||
|
var norm = NormalizePlayerId(playerId);
|
||||||
|
if (norm.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (skillId, xp) in skillXpTotals)
|
||||||
|
{
|
||||||
|
if (skillId.Trim().Length == 0 || xp < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresSkillProgressionBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
if (!PlayerExists(conn, norm, tx))
|
||||||
|
{
|
||||||
|
tx.Rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (skillId, xp) in skillXpTotals)
|
||||||
|
{
|
||||||
|
var sid = skillId.Trim();
|
||||||
|
if (xp == 0)
|
||||||
|
{
|
||||||
|
using var del = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
DELETE FROM player_skill_progression
|
||||||
|
WHERE player_id = @pid AND skill_id = @sid;
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
del.Parameters.AddWithValue("pid", norm);
|
||||||
|
del.Parameters.AddWithValue("sid", sid);
|
||||||
|
del.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var upsert = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO player_skill_progression (player_id, skill_id, xp, updated_at)
|
||||||
|
VALUES (@pid, @sid, @xp, now())
|
||||||
|
ON CONFLICT (player_id, skill_id)
|
||||||
|
DO UPDATE SET xp = EXCLUDED.xp, updated_at = now();
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
upsert.Parameters.AddWithValue("pid", norm);
|
||||||
|
upsert.Parameters.AddWithValue("sid", sid);
|
||||||
|
upsert.Parameters.AddWithValue("xp", xp);
|
||||||
|
upsert.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
|
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
|
||||||
{
|
{
|
||||||
using var cmd = new Npgsql.NpgsqlCommand(
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ On success, **Information** logs include the resolved mastery directory, track c
|
||||||
|
|
||||||
**Structured responses (HTTP 200):** **`selected`** (`true`/`false`), optional **`reasonCode`** on deny, authoritative **`perkState`** (same shape as GET), and **`unlockedEvents`** on success (empty on deny). Stable deny codes (see `PerkUnlockReasonCodes`): **`unknown_track`**, **`unknown_tier`**, **`unknown_branch`**, **`level_too_low`**, **`branch_already_chosen`**, **`tier_branch_not_selectable`**, **`player_not_found`**. On **`selected: true`**, **`unlockedEvents`** lists perks newly unlocked on this request (`perkId`, `skillId`, `tierIndex`, `branchId`, **`source`**: **`branch_pick`** | **`level_up`**). Tier-1 prototype salvage picks may emit zero events; path-auto tier-2 unlocks at level 4+ may emit **`level_up`** events on the same POST when level gates are already met.
|
**Structured responses (HTTP 200):** **`selected`** (`true`/`false`), optional **`reasonCode`** on deny, authoritative **`perkState`** (same shape as GET), and **`unlockedEvents`** on success (empty on deny). Stable deny codes (see `PerkUnlockReasonCodes`): **`unknown_track`**, **`unknown_tier`**, **`unknown_branch`**, **`level_too_low`**, **`branch_already_chosen`**, **`tier_branch_not_selectable`**, **`player_not_found`**. On **`selected: true`**, **`unlockedEvents`** lists perks newly unlocked on this request (`perkId`, `skillId`, `tierIndex`, `branchId`, **`source`**: **`branch_pick`** | **`level_up`**). Tier-1 prototype salvage picks may emit zero events; path-auto tier-2 unlocks at level 4+ may emit **`level_up`** events on the same POST when level gates are already met.
|
||||||
|
|
||||||
**Dev mastery fixture (NEO-48, Bruno/manual QA):** When `Game:EnableMasteryFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/mastery-fixture`** accepts `schemaVersion` **1**, optional **`resetPerkState`** (clear branch picks + unlocked perks), and optional **`skillXp`** map (absolute XP per `skillId`). **404** when disabled, player unknown, or store write fails. Not for production.
|
**Dev mastery fixture (NEO-48, Bruno/manual QA):** When `Game:EnableMasteryFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/mastery-fixture`** accepts `schemaVersion` **1**, optional **`resetPerkState`** (clear branch picks + unlocked perks), and optional **`skillXp`** map (**absolute** XP per `skillId`; omitted skills unchanged). Fixture writes set totals **directly** — they do **not** run **`SkillProgressionGrantOperations`** or perk **level-up reevaluation**; use normal **`POST …/skill-progression`** when testing unlock side effects. Apply order: batch **`skillXp`** first, then **`resetPerkState`** (XP rolls back if reset fails). **400** for bad schema or invalid **`skillXp`**; **404** when disabled, player unknown, or store write fails. Not for production.
|
||||||
|
|
||||||
Plan: [NEO-48 implementation plan](../../docs/plans/NEO-48-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-48.md`](../../docs/manual-qa/NEO-48.md); Bruno: `bruno/neon-sprawl-server/perk-state/`.
|
Plan: [NEO-48 implementation plan](../../docs/plans/NEO-48-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-48.md`](../../docs/manual-qa/NEO-48.md); Bruno: `bruno/neon-sprawl-server/perk-state/`.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue