From ffdbed26f27525bbafcbcc84729c0266fb05bc41 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 20:31:26 -0400 Subject: [PATCH] NEO-48: dev mastery-fixture API for Bruno state setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fixture player ids with POST …/__dev/mastery-fixture (resetPerkState, absolute skillXp). Bruno deny tests set state in pre-request on dev-local-1. Gated to Development/Testing or Game:EnableMasteryFixtureApi. --- .../neon-sprawl-server/environments/Local.bru | 2 - ...anch select deny branch already chosen.bru | 63 ++++------- .../Post branch select deny level too low.bru | 23 +++- .../neon-sprawl-server/perk-state/folder.bru | 3 +- docs/manual-qa/NEO-48.md | 17 ++- docs/plans/NEO-48-implementation-plan.md | 1 + .../Game/Mastery/MasteryFixtureApiTests.cs | 103 ++++++++++++++++++ .../Game/Mastery/PerkStateApiTests.cs | 21 ---- .../InMemoryWebApplicationFactory.cs | 1 + .../Game/Mastery/IPlayerPerkStateStore.cs | 3 + .../Mastery/InMemoryPlayerPerkStateStore.cs | 29 ++++- .../Game/Mastery/MasteryFixtureApi.cs | 41 +++++++ .../Game/Mastery/MasteryFixtureDtos.cs | 31 ++++++ .../Game/Mastery/MasteryFixtureOperations.cs | 39 +++++++ .../Mastery/PostgresPlayerPerkStateStore.cs | 40 +++++++ .../Game/PositionState/GamePlayerSeed.cs | 30 ----- .../Game/PositionState/GamePositionOptions.cs | 4 +- .../InMemoryPositionStateStore.cs | 10 +- .../PostgresPositionBootstrap.cs | 29 +++-- .../Skills/IPlayerSkillProgressionStore.cs | 3 + .../InMemoryPlayerSkillProgressionStore.cs | 34 +++++- .../PostgresPlayerSkillProgressionStore.cs | 58 ++++++++++ server/NeonSprawl.Server/Program.cs | 7 ++ .../appsettings.Development.json | 3 + server/NeonSprawl.Server/appsettings.json | 5 +- server/README.md | 2 +- 26 files changed, 460 insertions(+), 142 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs delete mode 100644 server/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs diff --git a/bruno/neon-sprawl-server/environments/Local.bru b/bruno/neon-sprawl-server/environments/Local.bru index aef1d6b..bd622e2 100644 --- a/bruno/neon-sprawl-server/environments/Local.bru +++ b/bruno/neon-sprawl-server/environments/Local.bru @@ -1,6 +1,4 @@ vars { baseUrl: http://localhost:5253 playerId: dev-local-1 - neo48PerkFreshPlayerId: neo48-perk-fresh - neo48PerkBranchPlayerId: neo48-perk-branch } diff --git a/bruno/neon-sprawl-server/perk-state/Post branch select deny branch already chosen.bru b/bruno/neon-sprawl-server/perk-state/Post branch select deny branch already chosen.bru index 42f4a6a..2398c8b 100644 --- a/bruno/neon-sprawl-server/perk-state/Post branch select deny branch already chosen.bru +++ b/bruno/neon-sprawl-server/perk-state/Post branch select deny branch already chosen.bru @@ -5,58 +5,43 @@ meta { } docs { - NEO-48: self-contained deny on fixture player neo48-perk-branch. Pre-request reaches level 2 and picks scrap_efficiency when needed; main request conflicts with bulk_haul. + NEO-48: pre-request sets salvage level 2 + tier-1 scrap_efficiency pick via mastery-fixture + perk-state; main request conflicts with bulk_haul. } script:pre-request { const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); - const playerId = bru.getEnvVar("neo48PerkBranchPlayerId") || bru.getVar("neo48PerkBranchPlayerId"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; - const progression = await axios.get( - `${baseUrl}/game/players/${playerId}/skill-progression`, + const reset = await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/mastery-fixture`, + { + schemaVersion: 1, + resetPerkState: true, + skillXp: { salvage: 100 }, + }, + jsonHeaders, ); - const salvage = progression.data?.skills?.find((s) => s.id === "salvage"); - const salvageLevel = salvage?.level ?? 1; - if (salvageLevel < 2) { - const grant = await axios.post( - `${baseUrl}/game/players/${playerId}/skill-progression`, - { - schemaVersion: 1, - skillId: "salvage", - amount: 100, - sourceKind: "activity", - }, - jsonHeaders, - ); - if (grant.status !== 200) { - throw new Error(`salvage grant failed: ${grant.status}`); - } + if (reset.status !== 200 || reset.data?.applied !== true) { + throw new Error(`mastery fixture reset failed: ${reset.status} ${JSON.stringify(reset.data)}`); } - const perkState = await axios.get(`${baseUrl}/game/players/${playerId}/perk-state`); - const salvageTrack = perkState.data?.branchPicks?.find((t) => t.skillId === "salvage"); - const hasTier1Pick = Boolean( - salvageTrack?.picks?.some((p) => p.tierIndex === 1), + const firstPick = await axios.post( + `${baseUrl}/game/players/${playerId}/perk-state`, + { + schemaVersion: 1, + skillId: "salvage", + tierIndex: 1, + branchId: "scrap_efficiency", + }, + jsonHeaders, ); - if (!hasTier1Pick) { - const firstPick = await axios.post( - `${baseUrl}/game/players/${playerId}/perk-state`, - { - schemaVersion: 1, - skillId: "salvage", - tierIndex: 1, - branchId: "scrap_efficiency", - }, - jsonHeaders, - ); - if (firstPick.status !== 200 || firstPick.data?.selected !== true) { - throw new Error(`tier-1 pick failed: ${firstPick.status} ${JSON.stringify(firstPick.data)}`); - } + if (firstPick.status !== 200 || firstPick.data?.selected !== true) { + throw new Error(`tier-1 pick failed: ${firstPick.status} ${JSON.stringify(firstPick.data)}`); } } post { - url: {{baseUrl}}/game/players/{{neo48PerkBranchPlayerId}}/perk-state + url: {{baseUrl}}/game/players/{{playerId}}/perk-state body: json auth: none } diff --git a/bruno/neon-sprawl-server/perk-state/Post branch select deny level too low.bru b/bruno/neon-sprawl-server/perk-state/Post branch select deny level too low.bru index 553fe79..fabb82a 100644 --- a/bruno/neon-sprawl-server/perk-state/Post branch select deny level too low.bru +++ b/bruno/neon-sprawl-server/perk-state/Post branch select deny level too low.bru @@ -5,11 +5,30 @@ meta { } docs { - NEO-48: salvage tier 1 requires skill level 2. Uses fixture player neo48-perk-fresh (seeded empty; not used by other requests). + NEO-48: salvage tier 1 requires skill level 2. Pre-request resets dev-local-1 via POST …/__dev/mastery-fixture. +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); + const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const jsonHeaders = { headers: { "Content-Type": "application/json" } }; + const reset = await axios.post( + `${baseUrl}/game/players/${playerId}/__dev/mastery-fixture`, + { + schemaVersion: 1, + resetPerkState: true, + skillXp: { salvage: 0 }, + }, + jsonHeaders, + ); + if (reset.status !== 200 || reset.data?.applied !== true) { + throw new Error(`mastery fixture reset failed: ${reset.status} ${JSON.stringify(reset.data)}`); + } } post { - url: {{baseUrl}}/game/players/{{neo48PerkFreshPlayerId}}/perk-state + url: {{baseUrl}}/game/players/{{playerId}}/perk-state body: json auth: none } diff --git a/bruno/neon-sprawl-server/perk-state/folder.bru b/bruno/neon-sprawl-server/perk-state/folder.bru index a5cd89e..d8c5be7 100644 --- a/bruno/neon-sprawl-server/perk-state/folder.bru +++ b/bruno/neon-sprawl-server/perk-state/folder.bru @@ -3,6 +3,5 @@ meta { } docs { - NEO-48 perk-state HTTP smoke. Fixture players neo48-perk-fresh and neo48-perk-branch are seeded in server appsettings (Game:FixturePlayerIds) so deny tests do not share dev-local-1 state. - level_too_low uses neo48-perk-fresh (always level 1 at seed). branch_already_chosen uses neo48-perk-branch (pre-request grants/picks when needed). + 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). } diff --git a/docs/manual-qa/NEO-48.md b/docs/manual-qa/NEO-48.md index 1140680..f5396a4 100644 --- a/docs/manual-qa/NEO-48.md +++ b/docs/manual-qa/NEO-48.md @@ -36,19 +36,24 @@ curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \ - [ ] `selected: true`; `perkState.branchPicks` contains salvage tier 1 `scrap_efficiency`; `unlockedEvents` empty at tier 1. - [ ] Repeat **GET** — same pick visible. -## POST deny (fixture players) +## POST deny (mastery fixture reset) -Server seeds **`neo48-perk-fresh`** and **`neo48-perk-branch`** (`Game:FixturePlayerIds` in `appsettings.json`) so Bruno deny tests do not share `dev-local-1` history. +Requires **`Game:EnableMasteryFixtureApi`** (on in Development) or Development environment. ```bash -# level_too_low (neo48-perk-fresh — no grant needed) -curl -sS -X POST "http://localhost:5253/game/players/neo48-perk-fresh/perk-state" \ +# Reset dev-local-1 to salvage level 1, no perks +curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/__dev/mastery-fixture" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"resetPerkState":true,"skillXp":{"salvage":0}}' | jq . + +curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \ -H "Content-Type: application/json" \ -d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}' | jq . +# expect level_too_low ``` -- [ ] `neo48-perk-fresh` → `level_too_low` without prior XP grant. -- [ ] `neo48-perk-branch`: grant 100 salvage XP, pick `scrap_efficiency`, then POST `bulk_haul` → **`branch_already_chosen`**. +- [ ] Fixture reset + branch POST → `level_too_low`. +- [ ] Fixture reset with `skillXp.salvage: 100`, pick `scrap_efficiency`, POST `bulk_haul` → **`branch_already_chosen`**. ## Bruno diff --git a/docs/plans/NEO-48-implementation-plan.md b/docs/plans/NEO-48-implementation-plan.md index 49b5749..2467fde 100644 --- a/docs/plans/NEO-48-implementation-plan.md +++ b/docs/plans/NEO-48-implementation-plan.md @@ -20,6 +20,7 @@ | **GET JSON shape** | Nested maps vs arrays? | **Agent (repo pattern):** serialize branch picks as a **`branchPicks`** array of `{ skillId, picks: [{ tierIndex, branchId }] }` (ordinal sort by `skillId`; picks sorted by `tierIndex`). **`unlockedPerkIds`** as a sorted string array. Internal `PerkStateSnapshot` mapping only — no rename of persisted fields. | | **Deny envelope** | Field names for success/deny? | **Agent (NEO-38 mirror):** HTTP **200** with **`selected`** (`true`/`false`), optional **`reasonCode`** (stable codes from `PerkUnlockReasonCodes`), and authoritative **`perkState`** on both paths. **400** for missing/wrong `schemaVersion`; **404** when player absent from position state (same gate as skill-progression GET). | | **Telemetry at HTTP** | Duplicate NEO-49 hook comments on POST? | **No** — [NEO-49 plan](NEO-49-implementation-plan.md): engine `TryUnlockPerks` is the authoritative hook; HTTP calls `TrySelectBranch` and surfaces returned `Events` only in JSON. | +| **Bruno deny setup** | Separate fixture players vs reset API? | **User (PR follow-up):** `POST …/__dev/mastery-fixture` sets `skillXp` + `resetPerkState`; Bruno pre-request calls it on `dev-local-1` before deny smokes (not separate seeded players). | ## Goal, scope, and out-of-scope diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs new file mode 100644 index 0000000..ecd2a16 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryFixtureApiTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using NeonSprawl.Server.Game.Mastery; +using NeonSprawl.Server.Game.Skills; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Mastery; + +public sealed class MasteryFixtureApiTests +{ + private const string DevPlayer = "dev-local-1"; + + [Fact] + public async Task PostMasteryFixture_ShouldReturnNotFound_WhenRouteNotRegistered() + { + // Arrange — Production + fixture flag off => endpoint not mapped + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.WithWebHostBuilder(b => + { + b.UseEnvironment("Production"); + b.UseSetting("Game:EnableMasteryFixtureApi", "false"); + }).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 }, + }); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostMasteryFixture_ShouldResetSalvageXpAndPerkState_ThenLevelTooLowDenies() + { + // 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); + + // 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 deny = await client.PostAsJsonAsync( + $"/game/players/{DevPlayer}/perk-state", + new PerkBranchSelectRequest + { + SchemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion, + SkillId = "salvage", + TierIndex = 1, + BranchId = "scrap_efficiency", + }); + + // Assert + Assert.Equal(HttpStatusCode.OK, reset.StatusCode); + var resetBody = await reset.Content.ReadFromJsonAsync(); + Assert.NotNull(resetBody); + Assert.True(resetBody!.Applied); + var progression = await client.GetFromJsonAsync( + $"/game/players/{DevPlayer}/skill-progression"); + var salvage = Assert.Single(progression!.Skills!, static s => s.Id == "salvage"); + Assert.Equal(0, salvage.Xp); + Assert.Equal(1, salvage.Level); + var perkGet = await client.GetFromJsonAsync( + $"/game/players/{DevPlayer}/perk-state"); + Assert.Empty(perkGet!.BranchPicks); + Assert.Equal(HttpStatusCode.OK, deny.StatusCode); + var envelope = await deny.Content.ReadFromJsonAsync(); + Assert.NotNull(envelope); + Assert.False(envelope!.Selected); + Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, envelope.ReasonCode); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs index b35cbbf..d83c843 100644 --- a/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs @@ -116,27 +116,6 @@ public sealed class PerkStateApiTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - [Fact] - public async Task PostPerkState_ShouldDenyLevelTooLow_OnFixturePlayer_WithoutXpGrant() - { - // Arrange — neo48-perk-fresh is seeded with empty progression (appsettings FixturePlayerIds) - const string freshPlayer = "neo48-perk-fresh"; - await using var factory = new InMemoryWebApplicationFactory(); - var client = factory.CreateClient(); - - // Act - var response = await client.PostAsJsonAsync( - $"/game/players/{freshPlayer}/perk-state", - SelectBranch("salvage", 1, "scrap_efficiency")); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var envelope = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(envelope); - Assert.False(envelope!.Selected); - Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, envelope.ReasonCode); - } - [Fact] public async Task PostPerkState_ShouldDenyLevelTooLow_WhenSalvageBelowTierGate() { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index a768863..4eb9aa1 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -32,6 +32,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs index 758c273..5ddc70e 100644 --- a/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs @@ -16,4 +16,7 @@ public interface IPlayerPerkStateStore /// Removes a branch pick (engine rollback when perk unlock fails after pick). bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex); + + /// Clears all branch picks and unlocked perks for the player (NEO-48 dev fixture API). + bool TryResetPerkState(string playerId); } diff --git a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs index a4dcb12..8e904fa 100644 --- a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs @@ -13,12 +13,9 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) { + var id = NormalizePlayerId(o.DevPlayerId); var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o)) - { - map[NormalizePlayerId(id)] = new PlayerPerkState(); - } - + map[id] = new PlayerPerkState(); return map; } @@ -110,6 +107,22 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o } } + /// + public bool TryResetPerkState(string playerId) + { + var key = NormalizePlayerId(playerId); + if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + inner.Reset(); + return true; + } + } + private static string NormalizePlayerId(string? playerId) { var t = playerId?.Trim(); @@ -146,6 +159,12 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o public bool RemoveBranchPick(string skillId, int tierIndex) => branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex); + public void Reset() + { + branchPicks.Clear(); + unlockedPerks.Clear(); + } + public PerkStateSnapshot ToSnapshot() { var bySkill = new Dictionary>(StringComparer.Ordinal); diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs new file mode 100644 index 0000000..5a06d1e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs @@ -0,0 +1,41 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Mastery; + +/// Dev-only route to reset/set mastery fixture state for Bruno and manual QA (NEO-48). +public static class MasteryFixtureApi +{ + public static WebApplication MapMasteryFixtureApi(this WebApplication app) + { + app.MapPost( + "/game/players/{id}/__dev/mastery-fixture", + (string id, MasteryFixtureRequest? body, IPositionStateStore positions, + IPlayerPerkStateStore perkStore, IPlayerSkillProgressionStore xpStore) => + { + if (body is null || body.SchemaVersion != MasteryFixtureRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + if (!MasteryFixtureOperations.TryApply(trimmedId, body, perkStore, xpStore)) + { + return Results.NotFound(); + } + + return Results.Json( + new MasteryFixtureResponse + { + Applied = true, + }); + }); + + return app; + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs new file mode 100644 index 0000000..18c9d31 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Mastery; + +/// POST body for POST /game/players/{{id}}/__dev/mastery-fixture (NEO-48 Bruno/manual QA). +public sealed class MasteryFixtureRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// When true, clears all branch picks and unlocked perks for the player. + [JsonPropertyName("resetPerkState")] + public bool ResetPerkState { get; init; } + + /// Absolute XP totals to set per skillId (omitted skills unchanged). + [JsonPropertyName("skillXp")] + public Dictionary? SkillXp { get; init; } +} + +public sealed class MasteryFixtureResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("applied")] + public bool Applied { get; init; } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs new file mode 100644 index 0000000..3b1c0f5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs @@ -0,0 +1,39 @@ +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Mastery; + +/// Applies dev-only mastery fixture resets (NEO-48). +public static class MasteryFixtureOperations +{ + public static bool TryApply( + string playerId, + MasteryFixtureRequest body, + IPlayerPerkStateStore perkStore, + IPlayerSkillProgressionStore xpStore) + { + if (body.ResetPerkState && !perkStore.TryResetPerkState(playerId)) + { + return false; + } + + if (body.SkillXp is null || body.SkillXp.Count == 0) + { + return true; + } + + foreach (var (skillId, xp) in body.SkillXp) + { + if (skillId.Trim().Length == 0 || xp < 0) + { + return false; + } + + if (!xpStore.TrySetSkillXpTotal(playerId, skillId, xp)) + { + return false; + } + } + + return true; + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs index 464eecc..d455cf8 100644 --- a/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs @@ -217,6 +217,46 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou return cmd.ExecuteNonQuery() > 0; } + /// + public bool TryResetPerkState(string playerId) + { + var norm = NormalizePlayerId(playerId); + if (norm.Length == 0) + { + return false; + } + + PostgresPerkStateBootstrap.EnsureSchema(dataSource); + using var conn = dataSource.OpenConnection(); + using var tx = conn.BeginTransaction(); + if (!PlayerExists(conn, norm, tx)) + { + tx.Rollback(); + return false; + } + + using (var picks = new Npgsql.NpgsqlCommand( + "DELETE FROM player_mastery_branch_pick WHERE player_id = @pid;", + conn, + tx)) + { + picks.Parameters.AddWithValue("pid", norm); + picks.ExecuteNonQuery(); + } + + using (var perks = new Npgsql.NpgsqlCommand( + "DELETE FROM player_unlocked_perk WHERE player_id = @pid;", + conn, + tx)) + { + perks.Parameters.AddWithValue("pid", norm); + perks.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/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs b/server/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs deleted file mode 100644 index 43ffbe4..0000000 --- a/server/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace NeonSprawl.Server.Game.PositionState; - -/// Configured player ids seeded at host start (dev player + Bruno/manual QA fixtures). -public static class GamePlayerSeed -{ - /// Distinct normalized player ids from and . - public static IReadOnlyList GetSeedPlayerIds(GamePositionOptions options) - { - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var list = new List(); - Add(options.DevPlayerId, seen, list); - foreach (var id in options.FixturePlayerIds) - { - Add(id, seen, list); - } - - return list; - } - - private static void Add(string? raw, HashSet seen, List list) - { - var t = raw?.Trim(); - if (string.IsNullOrEmpty(t) || !seen.Add(t)) - { - return; - } - - list.Add(t); - } -} diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs index 7124fae..334b824 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -10,8 +10,8 @@ public sealed class GamePositionOptions /// Logical player id seeded in the in-memory store (URL-safe string). public string DevPlayerId { get; set; } = "dev-local-1"; - /// Additional player ids seeded with default position and empty progression/perk state (Bruno/manual QA). - public List FixturePlayerIds { get; set; } = []; + /// When true, maps POST …/__dev/mastery-fixture for Bruno/manual QA state setup (also enabled in Development). + public bool EnableMasteryFixtureApi { get; set; } /// World position for the dev player at process start. public DefaultPositionOptions DefaultPosition { get; set; } = new(); diff --git a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs index cfd558e..62edcbf 100644 --- a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs +++ b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs @@ -10,19 +10,15 @@ public sealed class InMemoryPositionStateStore(IOptions opt private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) { - var ids = GamePlayerSeed.GetSeedPlayerIds(o); - if (ids.Count == 0) + var id = o.DevPlayerId.Trim(); + if (id.Length == 0) { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); } var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var p = o.DefaultPosition ?? new DefaultPositionOptions(); - foreach (var id in ids) - { - map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); - } - + map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); return map; } diff --git a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs index 91e4b8a..090c57a 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs @@ -55,23 +55,20 @@ public static class PostgresPositionBootstrap /// Idempotent insert using an existing open connection (e.g. test harness after TRUNCATE). public static void SeedDevPlayer(Npgsql.NpgsqlConnection connection, GamePositionOptions options) { + var devNorm = NormalizePlayerId(options.DevPlayerId); var p = options.DefaultPosition ?? new DefaultPositionOptions(); - foreach (var id in GamePlayerSeed.GetSeedPlayerIds(options)) - { - var norm = NormalizePlayerId(id); - using var cmd = new Npgsql.NpgsqlCommand( - """ - INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence) - VALUES (@pid, @x, @y, @z, 0) - ON CONFLICT (player_id) DO NOTHING; - """, - connection); - cmd.Parameters.AddWithValue("pid", norm); - cmd.Parameters.AddWithValue("x", p.X); - cmd.Parameters.AddWithValue("y", p.Y); - cmd.Parameters.AddWithValue("z", p.Z); - cmd.ExecuteNonQuery(); - } + using var cmd = new Npgsql.NpgsqlCommand( + """ + INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence) + VALUES (@pid, @x, @y, @z, 0) + ON CONFLICT (player_id) DO NOTHING; + """, + connection); + cmd.Parameters.AddWithValue("pid", devNorm); + cmd.Parameters.AddWithValue("x", p.X); + cmd.Parameters.AddWithValue("y", p.Y); + cmd.Parameters.AddWithValue("z", p.Z); + cmd.ExecuteNonQuery(); } private static string NormalizePlayerId(string? playerId) diff --git a/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs index bd183ed..b4ba656 100644 --- a/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs @@ -11,4 +11,7 @@ public interface IPlayerSkillProgressionStore /// Returns false when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from player_position). /// bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp); + + /// Sets absolute XP for one skill (NEO-48 dev fixture API). must be >= 0. + bool TrySetSkillXpTotal(string playerId, string skillId, int xp); } diff --git a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs index 8bdf271..61f691d 100644 --- a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs @@ -14,12 +14,9 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions> CreateInitialMap(GamePositionOptions o) { + var id = NormalizePlayerId(o.DevPlayerId); var map = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o)) - { - map[NormalizePlayerId(id)] = new ConcurrentDictionary(StringComparer.Ordinal); - } - + map[id] = new ConcurrentDictionary(StringComparer.Ordinal); return map; } @@ -66,6 +63,33 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions + public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) + { + if (xp < 0) + { + return false; + } + + var key = NormalizePlayerId(playerId); + var sid = skillId.Trim(); + if (key.Length == 0 || sid.Length == 0 || !byPlayer.TryGetValue(key, out var inner)) + { + return false; + } + + lock (playerLocks.GetOrAdd(key, _ => new object())) + { + if (xp == 0) + { + return inner.TryRemove(sid, out _); + } + + 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 0a0f221..c802601 100644 --- a/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs @@ -98,6 +98,64 @@ public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource return true; } + /// + public bool TrySetSkillXpTotal(string playerId, string skillId, int xp) + { + if (xp < 0) + { + return false; + } + + var norm = NormalizePlayerId(playerId); + var sid = skillId.Trim(); + if (norm.Length == 0 || sid.Length == 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; + } + + 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/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index dae8a54..a7aaf11 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -36,6 +36,13 @@ app.MapInteractablesWorldApi(); app.MapSkillDefinitionsWorldApi(); app.MapSkillProgressionSnapshotApi(); app.MapPerkStateApi(); +if (app.Environment.IsDevelopment() || + app.Environment.IsEnvironment("Testing") || + app.Configuration.GetValue("Game:EnableMasteryFixtureApi")) +{ + app.MapMasteryFixtureApi(); +} + app.MapTargetingApi(); app.MapHotbarLoadoutApi(); app.MapCooldownSnapshotApi(); diff --git a/server/NeonSprawl.Server/appsettings.Development.json b/server/NeonSprawl.Server/appsettings.Development.json index 0c208ae..d37883a 100644 --- a/server/NeonSprawl.Server/appsettings.Development.json +++ b/server/NeonSprawl.Server/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Game": { + "EnableMasteryFixtureApi": true } } diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index a207a92..be0b44f 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -12,10 +12,7 @@ }, "Game": { "DevPlayerId": "dev-local-1", - "FixturePlayerIds": [ - "neo48-perk-fresh", - "neo48-perk-branch" - ], + "EnableMasteryFixtureApi": false, "DefaultPosition": { "X": -5, "Y": 0.9, diff --git a/server/README.md b/server/README.md index 886639f..1243b57 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. -**Bruno fixture players (NEO-48):** `Game:FixturePlayerIds` in `appsettings.json` seeds additional player ids (`neo48-perk-fresh`, `neo48-perk-branch`) with default position and empty progression/perk state so deny smokes do not depend on `dev-local-1` history. +**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. 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/`.