From b72201b1f0fabe3e1477106edf3255ad1072cf92 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 20:26:21 -0400 Subject: [PATCH] NEO-48: Bruno fixture players for deterministic perk deny tests Seed neo48-perk-fresh and neo48-perk-branch via Game:FixturePlayerIds; level_too_low uses isolated fresh player, branch_already_chosen sets up neo48-perk-branch in pre-request instead of skipping on shared state. --- .../neon-sprawl-server/environments/Local.bru | 2 + ...anch select deny branch already chosen.bru | 63 +++++++++++-------- .../Post branch select deny level too low.bru | 34 +--------- .../neon-sprawl-server/perk-state/folder.bru | 5 +- docs/manual-qa/NEO-48.md | 15 ++++- .../Game/Mastery/PerkStateApiTests.cs | 21 +++++++ .../Mastery/InMemoryPlayerPerkStateStore.cs | 7 ++- .../Game/PositionState/GamePlayerSeed.cs | 30 +++++++++ .../Game/PositionState/GamePositionOptions.cs | 3 + .../InMemoryPositionStateStore.cs | 10 ++- .../PostgresPositionBootstrap.cs | 31 ++++----- .../InMemoryPlayerSkillProgressionStore.cs | 7 ++- server/NeonSprawl.Server/appsettings.json | 4 ++ server/README.md | 2 + 14 files changed, 151 insertions(+), 83 deletions(-) create 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 bd622e2..aef1d6b 100644 --- a/bruno/neon-sprawl-server/environments/Local.bru +++ b/bruno/neon-sprawl-server/environments/Local.bru @@ -1,4 +1,6 @@ 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 0503cb1..42f4a6a 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,45 +5,58 @@ meta { } docs { - NEO-48: self-contained deny — pre-request grants salvage to level 2 and picks scrap_efficiency; - main request conflicts with bulk_haul on the same tier. Expects selected false + branch_already_chosen. + 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. } script:pre-request { const axios = require("axios"); const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); - const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); + const playerId = bru.getEnvVar("neo48PerkBranchPlayerId") || bru.getVar("neo48PerkBranchPlayerId"); const jsonHeaders = { headers: { "Content-Type": "application/json" } }; - const grant = await axios.post( + const progression = await axios.get( `${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}`); + 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}`); + } } - const firstPick = await axios.post( - `${baseUrl}/game/players/${playerId}/perk-state`, - { - schemaVersion: 1, - skillId: "salvage", - tierIndex: 1, - branchId: "scrap_efficiency", - }, - jsonHeaders, + 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), ); - if (firstPick.status !== 200 || firstPick.data?.selected !== true) { - throw new Error(`tier-1 pick failed: ${firstPick.status} ${JSON.stringify(firstPick.data)}`); + 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)}`); + } } } post { - url: {{baseUrl}}/game/players/{{playerId}}/perk-state + url: {{baseUrl}}/game/players/{{neo48PerkBranchPlayerId}}/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 1ce851f..553fe79 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,39 +5,11 @@ meta { } docs { - NEO-48: salvage tier 1 requires skill level 2. On a fresh server (no prior salvage XP), expects selected false + level_too_low. - Skipped automatically when dev-local-1 already has salvage level 2+ or a tier-1 branch pick (shared state after other Bruno requests). -} - -script:pre-request { - const axios = require("axios"); - const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"); - const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId"); - let salvageLevel = 1; - let hasTier1Pick = false; - try { - const progression = await axios.get( - `${baseUrl}/game/players/${playerId}/skill-progression`, - ); - const salvage = progression.data?.skills?.find((s) => s.id === "salvage"); - salvageLevel = salvage?.level ?? 1; - const perkState = await axios.get(`${baseUrl}/game/players/${playerId}/perk-state`); - const salvageTrack = perkState.data?.branchPicks?.find((t) => t.skillId === "salvage"); - hasTier1Pick = Boolean( - salvageTrack?.picks?.some((p) => p.tierIndex === 1), - ); - } catch (_) { - /* server down or player missing — let the request surface the error */ - } - if (salvageLevel >= 2 || hasTier1Pick) { - bru.skipRequest( - "dev-local-1 already progressed (salvage level 2+ or tier-1 pick); restart server for strict level_too_low smoke", - ); - } + NEO-48: salvage tier 1 requires skill level 2. Uses fixture player neo48-perk-fresh (seeded empty; not used by other requests). } post { - url: {{baseUrl}}/game/players/{{playerId}}/perk-state + url: {{baseUrl}}/game/players/{{neo48PerkFreshPlayerId}}/perk-state body: json auth: none } @@ -59,6 +31,6 @@ tests { expect(body.selected).to.equal(false); expect(body.reasonCode).to.equal("level_too_low"); expect(body.unlockedEvents).to.be.an("array").that.is.empty; - expect(body.perkState.branchPicks).to.be.an("array"); + expect(body.perkState.branchPicks).to.be.an("array").that.is.empty; }); } diff --git a/bruno/neon-sprawl-server/perk-state/folder.bru b/bruno/neon-sprawl-server/perk-state/folder.bru index 9d97eb3..a5cd89e 100644 --- a/bruno/neon-sprawl-server/perk-state/folder.bru +++ b/bruno/neon-sprawl-server/perk-state/folder.bru @@ -3,7 +3,6 @@ meta { } docs { - NEO-48 perk-state HTTP smoke. Run folder in seq order for full coverage. - Post branch select deny level too low: skipped via pre-request when dev-local-1 already has salvage level 2+ or a tier-1 branch pick (normal after other requests in a full collection run). - Post branch select deny branch already chosen: self-contained (pre-request grant + first pick); safe to re-run. + 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). } diff --git a/docs/manual-qa/NEO-48.md b/docs/manual-qa/NEO-48.md index a6e4506..1140680 100644 --- a/docs/manual-qa/NEO-48.md +++ b/docs/manual-qa/NEO-48.md @@ -36,10 +36,19 @@ 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 +## POST deny (fixture players) -- [ ] Second pick same tier different branch → `selected: false`, `reasonCode` **`branch_already_chosen`**. -- [ ] On fresh server without XP grant → `level_too_low`. +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. + +```bash +# level_too_low (neo48-perk-fresh — no grant needed) +curl -sS -X POST "http://localhost:5253/game/players/neo48-perk-fresh/perk-state" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}' | jq . +``` + +- [ ] `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`**. ## Bruno diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs index d83c843..b35cbbf 100644 --- a/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs @@ -116,6 +116,27 @@ 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/Game/Mastery/InMemoryPlayerPerkStateStore.cs b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs index d47a4de..a4dcb12 100644 --- a/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs +++ b/server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs @@ -13,9 +13,12 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions o private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) { - var id = NormalizePlayerId(o.DevPlayerId); var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - map[id] = new PlayerPerkState(); + foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o)) + { + map[NormalizePlayerId(id)] = new PlayerPerkState(); + } + return map; } diff --git a/server/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs b/server/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs new file mode 100644 index 0000000..43ffbe4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/PositionState/GamePlayerSeed.cs @@ -0,0 +1,30 @@ +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 aacc85b..7124fae 100644 --- a/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs +++ b/server/NeonSprawl.Server/Game/PositionState/GamePositionOptions.cs @@ -10,6 +10,9 @@ 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; } = []; + /// 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 62edcbf..cfd558e 100644 --- a/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs +++ b/server/NeonSprawl.Server/Game/PositionState/InMemoryPositionStateStore.cs @@ -10,15 +10,19 @@ public sealed class InMemoryPositionStateStore(IOptions opt private static ConcurrentDictionary CreateInitialMap(GamePositionOptions o) { - var id = o.DevPlayerId.Trim(); - if (id.Length == 0) + var ids = GamePlayerSeed.GetSeedPlayerIds(o); + if (ids.Count == 0) { throw new InvalidOperationException("Game:DevPlayerId must be non-empty."); } var map = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var p = o.DefaultPosition ?? new DefaultPositionOptions(); - map[id] = new PositionSnapshot(p.X, p.Y, p.Z, 0); + foreach (var id in ids) + { + 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 76796b1..91e4b8a 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PostgresPositionBootstrap.cs @@ -45,7 +45,7 @@ public static class PostgresPositionBootstrap } } - /// Idempotent insert for at default position. + /// Idempotent insert for configured seed players at default position. public static void SeedDevPlayer(Npgsql.NpgsqlDataSource dataSource, GamePositionOptions options) { using var conn = dataSource.OpenConnection(); @@ -55,20 +55,23 @@ 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(); - 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(); + 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(); + } } private static string NormalizePlayerId(string? playerId) diff --git a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs index cadd39f..8bdf271 100644 --- a/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs +++ b/server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs @@ -14,9 +14,12 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions> CreateInitialMap(GamePositionOptions o) { - var id = NormalizePlayerId(o.DevPlayerId); var map = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - map[id] = new ConcurrentDictionary(StringComparer.Ordinal); + foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o)) + { + map[NormalizePlayerId(id)] = new ConcurrentDictionary(StringComparer.Ordinal); + } + return map; } diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index 394a263..a207a92 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -12,6 +12,10 @@ }, "Game": { "DevPlayerId": "dev-local-1", + "FixturePlayerIds": [ + "neo48-perk-fresh", + "neo48-perk-branch" + ], "DefaultPosition": { "X": -5, "Y": 0.9, diff --git a/server/README.md b/server/README.md index ab0fba2..886639f 100644 --- a/server/README.md +++ b/server/README.md @@ -64,6 +64,8 @@ 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. + 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/`. ```bash