From aba26446d330a1d902eeca5c1ad47d22a06ef6c8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 20:37:00 -0400 Subject: [PATCH] =?UTF-8?q?NEO-48:=20address=20r2=20review=20=E2=80=94=20a?= =?UTF-8?q?tomic=20fixture,=20tests,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch skillXp then resetPerkState with XP rollback; 400 for invalid skillXp; MasteryFixtureApi gate tests; README fixture vs grant semantics; Bruno GET playerId. --- .../perk-state/Get perk state.bru | 4 +- .../neon-sprawl-server/perk-state/folder.bru | 1 + docs/plans/NEO-48-implementation-plan.md | 12 +- docs/reviews/2026-05-17-NEO-48-r2.md | 17 ++- .../Game/Mastery/MasteryFixtureApiTests.cs | 143 +++++++++++++----- .../Game/Mastery/MasteryFixtureApi.cs | 5 + .../Game/Mastery/MasteryFixtureOperations.cs | 57 +++++-- .../Skills/IPlayerSkillProgressionStore.cs | 3 + .../InMemoryPlayerSkillProgressionStore.cs | 36 +++++ .../PostgresPlayerSkillProgressionStore.cs | 64 ++++++++ server/README.md | 2 +- 11 files changed, 285 insertions(+), 59 deletions(-) diff --git a/bruno/neon-sprawl-server/perk-state/Get perk state.bru b/bruno/neon-sprawl-server/perk-state/Get perk state.bru index 8567c13..80270ac 100644 --- a/bruno/neon-sprawl-server/perk-state/Get perk state.bru +++ b/bruno/neon-sprawl-server/perk-state/Get perk state.bru @@ -10,7 +10,7 @@ docs { } get { - url: {{baseUrl}}/game/players/dev-local-1/perk-state + url: {{baseUrl}}/game/players/{{playerId}}/perk-state body: none auth: none } @@ -21,7 +21,7 @@ tests { 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.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); expect(body.branchPicks).to.be.an("array"); expect(body.unlockedPerkIds).to.be.an("array"); }); diff --git a/bruno/neon-sprawl-server/perk-state/folder.bru b/bruno/neon-sprawl-server/perk-state/folder.bru index d8c5be7..43b8d00 100644 --- a/bruno/neon-sprawl-server/perk-state/folder.bru +++ b/bruno/neon-sprawl-server/perk-state/folder.bru @@ -4,4 +4,5 @@ meta { 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). + 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. } diff --git a/docs/plans/NEO-48-implementation-plan.md b/docs/plans/NEO-48-implementation-plan.md index 2467fde..d612f9e 100644 --- a/docs/plans/NEO-48-implementation-plan.md +++ b/docs/plans/NEO-48-implementation-plan.md @@ -83,6 +83,9 @@ |------|---------| | `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/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. | | `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. | @@ -94,7 +97,13 @@ | 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. | | `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. | @@ -105,6 +114,7 @@ | 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). | +| `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. | 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. diff --git a/docs/reviews/2026-05-17-NEO-48-r2.md b/docs/reviews/2026-05-17-NEO-48-r2.md index 98d4a0f..13a4f02 100644 --- a/docs/reviews/2026-05-17-NEO-48-r2.md +++ b/docs/reviews/2026-05-17-NEO-48-r2.md @@ -3,7 +3,8 @@ **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 **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 @@ -17,7 +18,7 @@ Follow-up work closes the first-review Bruno/test gaps and replaces a short-live | 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/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. | @@ -30,16 +31,16 @@ None. ## 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. -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. -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). +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.~~ **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).~~ **Done.** README Perk state section. ## 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: 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: `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: 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`.~~ **Done.** `ArrangeSalvageProgressedWithTier1PickAsync`. ## Delta since first review (testing refactor) diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs index ecd2a16..959254a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs @@ -1,5 +1,7 @@ using System.Net; +using System.Net.Http; using System.Net.Http.Json; +using System.Text; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using NeonSprawl.Server.Game.Mastery; @@ -11,11 +13,46 @@ namespace NeonSprawl.Server.Tests.Game.Mastery; public sealed class MasteryFixtureApiTests { 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? skillXp = null) => + new MasteryFixtureRequest + { + SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion, + ResetPerkState = resetPerkState, + SkillXp = skillXp ?? new Dictionary { ["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] public async Task PostMasteryFixture_ShouldReturnNotFound_WhenRouteNotRegistered() { - // Arrange — Production + fixture flag off => endpoint not mapped + // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.WithWebHostBuilder(b => { @@ -23,15 +60,77 @@ public sealed class MasteryFixtureApiTests b.UseSetting("Game:EnableMasteryFixtureApi", "false"); }).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 var response = await client.PostAsJsonAsync( - $"/game/players/{DevPlayer}/__dev/mastery-fixture", - new MasteryFixtureRequest - { - SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion, - ResetPerkState = true, - SkillXp = new Dictionary { ["salvage"] = 0 }, - }); + FixturePath, + ValidFixture(skillXp: new Dictionary { ["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.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -43,34 +142,10 @@ public sealed class MasteryFixtureApiTests // Arrange await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); - Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync( - $"/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); + await ArrangeSalvageProgressedWithTier1PickAsync(client); // Act - var reset = await client.PostAsJsonAsync( - $"/game/players/{DevPlayer}/__dev/mastery-fixture", - new MasteryFixtureRequest - { - SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion, - ResetPerkState = true, - SkillXp = new Dictionary { ["salvage"] = 0 }, - }); + var reset = await client.PostAsJsonAsync(FixturePath, ValidFixture()); var deny = await client.PostAsJsonAsync( $"/game/players/{DevPlayer}/perk-state", new PerkBranchSelectRequest diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs index 5a06d1e..835b87c 100644 --- a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs @@ -18,6 +18,11 @@ public static class MasteryFixtureApi return Results.BadRequest(); } + if (!MasteryFixtureOperations.TryNormalizeSkillXp(body.SkillXp, out _)) + { + return Results.BadRequest(); + } + var trimmedId = id.Trim(); if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) { diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs index 3b1c0f5..e4612f6 100644 --- a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs @@ -5,35 +5,66 @@ namespace NeonSprawl.Server.Game.Mastery; /// Applies dev-only mastery fixture resets (NEO-48). public static class MasteryFixtureOperations { + /// Validates entries; returns false when any key is blank or XP is negative. + public static bool TryNormalizeSkillXp( + IReadOnlyDictionary? skillXp, + out Dictionary normalized) + { + normalized = new Dictionary(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; + } + + /// Applies skill XP (batch, then perk reset). Rolls back XP when perk reset fails after XP writes. public static bool TryApply( string playerId, MasteryFixtureRequest body, IPlayerPerkStateStore perkStore, IPlayerSkillProgressionStore xpStore) { - if (body.ResetPerkState && !perkStore.TryResetPerkState(playerId)) + if (!TryNormalizeSkillXp(body.SkillXp, out var skillXp)) { return false; } - if (body.SkillXp is null || body.SkillXp.Count == 0) + var previousXp = new Dictionary(xpStore.GetXpTotals(playerId), StringComparer.Ordinal); + + if (skillXp.Count > 0 && !xpStore.TrySetSkillXpTotals(playerId, skillXp)) + { + return false; + } + + if (!body.ResetPerkState) { 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; } } diff --git a/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs index b4ba656..c743f01 100644 --- a/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs @@ -14,4 +14,7 @@ public interface IPlayerSkillProgressionStore /// Sets absolute XP for one skill (NEO-48 dev fixture API). must be >= 0. bool TrySetSkillXpTotal(string playerId, string skillId, int xp); + + /// Sets absolute XP for multiple skills atomically (NEO-48 dev fixture API). + bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary skillXpTotals); } diff --git a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs index 61f691d..fa1a1bd 100644 --- a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs @@ -90,6 +90,42 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions + public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary 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) { var t = playerId?.Trim(); diff --git a/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs index c802601..2f10add 100644 --- a/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs @@ -156,6 +156,70 @@ public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource return true; } + /// + public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary 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) { using var cmd = new Npgsql.NpgsqlCommand( diff --git a/server/README.md b/server/README.md index 1243b57..8eef9d9 100644 --- a/server/README.md +++ b/server/README.md @@ -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. -**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/`.