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
parent
f4c3fe86e4
commit
b72201b1f0
|
|
@ -1,4 +1,6 @@
|
|||
vars {
|
||||
baseUrl: http://localhost:5253
|
||||
playerId: dev-local-1
|
||||
neo48PerkFreshPlayerId: neo48-perk-fresh
|
||||
neo48PerkBranchPlayerId: neo48-perk-branch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@
|
|||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
"FixturePlayerIds": [
|
||||
"neo48-perk-fresh",
|
||||
"neo48-perk-branch"
|
||||
],
|
||||
"DefaultPosition": {
|
||||
"X": -5,
|
||||
"Y": 0.9,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue