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.
pull/83/head
VinPropane 2026-05-17 20:26:21 -04:00
parent f4c3fe86e4
commit b72201b1f0
14 changed files with 151 additions and 83 deletions

View File

@ -1,4 +1,6 @@
vars {
baseUrl: http://localhost:5253
playerId: dev-local-1
neo48PerkFreshPlayerId: neo48-perk-fresh
neo48PerkBranchPlayerId: neo48-perk-branch
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyLevelTooLow_WhenSalvageBelowTierGate()
{

View File

@ -13,9 +13,12 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
private static ConcurrentDictionary<string, PlayerPerkState> CreateInitialMap(GamePositionOptions o)
{
var id = NormalizePlayerId(o.DevPlayerId);
var map = new ConcurrentDictionary<string, PlayerPerkState>(StringComparer.OrdinalIgnoreCase);
map[id] = new PlayerPerkState();
foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o))
{
map[NormalizePlayerId(id)] = new PlayerPerkState();
}
return map;
}

View File

@ -0,0 +1,30 @@
namespace NeonSprawl.Server.Game.PositionState;
/// <summary>Configured player ids seeded at host start (dev player + Bruno/manual QA fixtures).</summary>
public static class GamePlayerSeed
{
/// <summary>Distinct normalized player ids from <see cref="GamePositionOptions.DevPlayerId"/> and <see cref="GamePositionOptions.FixturePlayerIds"/>.</summary>
public static IReadOnlyList<string> GetSeedPlayerIds(GamePositionOptions options)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var list = new List<string>();
Add(options.DevPlayerId, seen, list);
foreach (var id in options.FixturePlayerIds)
{
Add(id, seen, list);
}
return list;
}
private static void Add(string? raw, HashSet<string> seen, List<string> list)
{
var t = raw?.Trim();
if (string.IsNullOrEmpty(t) || !seen.Add(t))
{
return;
}
list.Add(t);
}
}

View File

@ -10,6 +10,9 @@ public sealed class GamePositionOptions
/// <summary>Logical player id seeded in the in-memory store (URL-safe string).</summary>
public string DevPlayerId { get; set; } = "dev-local-1";
/// <summary>Additional player ids seeded with default position and empty progression/perk state (Bruno/manual QA).</summary>
public List<string> FixturePlayerIds { get; set; } = [];
/// <summary>World position for the dev player at process start.</summary>
public DefaultPositionOptions DefaultPosition { get; set; } = new();

View File

@ -10,15 +10,19 @@ public sealed class InMemoryPositionStateStore(IOptions<GamePositionOptions> opt
private static ConcurrentDictionary<string, PositionSnapshot> 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<string, PositionSnapshot>(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;
}

View File

@ -45,7 +45,7 @@ public static class PostgresPositionBootstrap
}
}
/// <summary>Idempotent insert for <see cref="GamePositionOptions.DevPlayerId"/> at default position.</summary>
/// <summary>Idempotent insert for configured seed players at default position.</summary>
public static void SeedDevPlayer(Npgsql.NpgsqlDataSource dataSource, GamePositionOptions options)
{
using var conn = dataSource.OpenConnection();
@ -55,20 +55,23 @@ public static class PostgresPositionBootstrap
/// <summary>Idempotent insert using an existing open connection (e.g. test harness after <c>TRUNCATE</c>).</summary>
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)

View File

@ -14,9 +14,12 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions<GamePositionOpt
private static ConcurrentDictionary<string, ConcurrentDictionary<string, int>> CreateInitialMap(GamePositionOptions o)
{
var id = NormalizePlayerId(o.DevPlayerId);
var map = new ConcurrentDictionary<string, ConcurrentDictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
map[id] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o))
{
map[NormalizePlayerId(id)] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
}
return map;
}

View File

@ -12,6 +12,10 @@
},
"Game": {
"DevPlayerId": "dev-local-1",
"FixturePlayerIds": [
"neo48-perk-fresh",
"neo48-perk-branch"
],
"DefaultPosition": {
"X": -5,
"Y": 0.9,

View File

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