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
VinPropane 2026-05-17 20:37:00 -04:00
parent 2bb02d76e9
commit aba26446d3
11 changed files with 285 additions and 59 deletions

View File

@ -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");
}); });

View File

@ -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 34) before happy-path POST tier1 if you need a clean dev-local-1; deny pre-requests reset shared player state.
} }

View File

@ -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.

View File

@ -3,7 +3,8 @@
**Date:** 2026-05-17 **Date:** 2026-05-17
**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 13 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 Brunos 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 Brunos 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)

View File

@ -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 =>
{ {
@ -23,15 +60,77 @@ public sealed class MasteryFixtureApiTests
b.UseSetting("Game:EnableMasteryFixtureApi", "false"); b.UseSetting("Game:EnableMasteryFixtureApi", "false");
}).CreateClient(); }).CreateClient();
// Act
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture());
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
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 // Act
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/__dev/mastery-fixture", FixturePath,
new MasteryFixtureRequest ValidFixture(skillXp: new Dictionary<string, int> { ["salvage"] = -1 }));
{
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion, // Assert
ResetPerkState = true, Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
SkillXp = new Dictionary<string, int> { ["salvage"] = 0 }, }
});
[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

View File

@ -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 _))
{ {

View File

@ -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 true;
{
return false;
}
if (!xpStore.TrySetSkillXpTotal(playerId, skillId, xp))
{
return false;
}
} }
return true; if (skillXp.Count > 0)
{
_ = xpStore.TrySetSkillXpTotals(playerId, previousXp);
}
return false;
} }
} }

View File

@ -14,4 +14,7 @@ public interface IPlayerSkillProgressionStore
/// <summary>Sets absolute XP for one skill (NEO-48 dev fixture API). <paramref name="xp"/> must be &gt;= 0.</summary> /// <summary>Sets absolute XP for one skill (NEO-48 dev fixture API). <paramref name="xp"/> must be &gt;= 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);
} }

View File

@ -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();

View File

@ -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(

View File

@ -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/`.