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 {
|
vars {
|
||||||
baseUrl: http://localhost:5253
|
baseUrl: http://localhost:5253
|
||||||
playerId: dev-local-1
|
playerId: dev-local-1
|
||||||
|
neo48PerkFreshPlayerId: neo48-perk-fresh
|
||||||
|
neo48PerkBranchPlayerId: neo48-perk-branch
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,45 +5,58 @@ meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-48: self-contained deny — pre-request grants salvage to level 2 and picks scrap_efficiency;
|
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.
|
||||||
main request conflicts with bulk_haul on the same tier. Expects selected false + branch_already_chosen.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
script:pre-request {
|
script:pre-request {
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
|
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 jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||||
const grant = await axios.post(
|
const progression = await axios.get(
|
||||||
`${baseUrl}/game/players/${playerId}/skill-progression`,
|
`${baseUrl}/game/players/${playerId}/skill-progression`,
|
||||||
{
|
|
||||||
schemaVersion: 1,
|
|
||||||
skillId: "salvage",
|
|
||||||
amount: 100,
|
|
||||||
sourceKind: "activity",
|
|
||||||
},
|
|
||||||
jsonHeaders,
|
|
||||||
);
|
);
|
||||||
if (grant.status !== 200) {
|
const salvage = progression.data?.skills?.find((s) => s.id === "salvage");
|
||||||
throw new Error(`salvage grant failed: ${grant.status}`);
|
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(
|
const perkState = await axios.get(`${baseUrl}/game/players/${playerId}/perk-state`);
|
||||||
`${baseUrl}/game/players/${playerId}/perk-state`,
|
const salvageTrack = perkState.data?.branchPicks?.find((t) => t.skillId === "salvage");
|
||||||
{
|
const hasTier1Pick = Boolean(
|
||||||
schemaVersion: 1,
|
salvageTrack?.picks?.some((p) => p.tierIndex === 1),
|
||||||
skillId: "salvage",
|
|
||||||
tierIndex: 1,
|
|
||||||
branchId: "scrap_efficiency",
|
|
||||||
},
|
|
||||||
jsonHeaders,
|
|
||||||
);
|
);
|
||||||
if (firstPick.status !== 200 || firstPick.data?.selected !== true) {
|
if (!hasTier1Pick) {
|
||||||
throw new Error(`tier-1 pick failed: ${firstPick.status} ${JSON.stringify(firstPick.data)}`);
|
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 {
|
post {
|
||||||
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
|
url: {{baseUrl}}/game/players/{{neo48PerkBranchPlayerId}}/perk-state
|
||||||
body: json
|
body: json
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,39 +5,11 @@ meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-48: salvage tier 1 requires skill level 2. On a fresh server (no prior salvage XP), expects selected false + level_too_low.
|
NEO-48: salvage tier 1 requires skill level 2. Uses fixture player neo48-perk-fresh (seeded empty; not used by other requests).
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
|
url: {{baseUrl}}/game/players/{{neo48PerkFreshPlayerId}}/perk-state
|
||||||
body: json
|
body: json
|
||||||
auth: none
|
auth: none
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +31,6 @@ tests {
|
||||||
expect(body.selected).to.equal(false);
|
expect(body.selected).to.equal(false);
|
||||||
expect(body.reasonCode).to.equal("level_too_low");
|
expect(body.reasonCode).to.equal("level_too_low");
|
||||||
expect(body.unlockedEvents).to.be.an("array").that.is.empty;
|
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 {
|
docs {
|
||||||
NEO-48 perk-state HTTP smoke. Run folder in seq order for full coverage.
|
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.
|
||||||
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).
|
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).
|
||||||
Post branch select deny branch already chosen: self-contained (pre-request grant + first pick); safe to re-run.
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- [ ] `selected: true`; `perkState.branchPicks` contains salvage tier 1 `scrap_efficiency`; `unlockedEvents` empty at tier 1.
|
||||||
- [ ] Repeat **GET** — same pick visible.
|
- [ ] Repeat **GET** — same pick visible.
|
||||||
|
|
||||||
## POST deny
|
## POST deny (fixture players)
|
||||||
|
|
||||||
- [ ] Second pick same tier different branch → `selected: false`, `reasonCode` **`branch_already_chosen`**.
|
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.
|
||||||
- [ ] On fresh server without XP grant → `level_too_low`.
|
|
||||||
|
```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
|
## Bruno
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,27 @@ public sealed class PerkStateApiTests
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task PostPerkState_ShouldDenyLevelTooLow_WhenSalvageBelowTierGate()
|
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)
|
private static ConcurrentDictionary<string, PlayerPerkState> CreateInitialMap(GamePositionOptions o)
|
||||||
{
|
{
|
||||||
var id = NormalizePlayerId(o.DevPlayerId);
|
|
||||||
var map = new ConcurrentDictionary<string, PlayerPerkState>(StringComparer.OrdinalIgnoreCase);
|
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;
|
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>
|
/// <summary>Logical player id seeded in the in-memory store (URL-safe string).</summary>
|
||||||
public string DevPlayerId { get; set; } = "dev-local-1";
|
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>
|
/// <summary>World position for the dev player at process start.</summary>
|
||||||
public DefaultPositionOptions DefaultPosition { get; set; } = new();
|
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)
|
private static ConcurrentDictionary<string, PositionSnapshot> CreateInitialMap(GamePositionOptions o)
|
||||||
{
|
{
|
||||||
var id = o.DevPlayerId.Trim();
|
var ids = GamePlayerSeed.GetSeedPlayerIds(o);
|
||||||
if (id.Length == 0)
|
if (ids.Count == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
throw new InvalidOperationException("Game:DevPlayerId must be non-empty.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var map = new ConcurrentDictionary<string, PositionSnapshot>(StringComparer.OrdinalIgnoreCase);
|
var map = new ConcurrentDictionary<string, PositionSnapshot>(StringComparer.OrdinalIgnoreCase);
|
||||||
var p = o.DefaultPosition ?? new DefaultPositionOptions();
|
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;
|
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)
|
public static void SeedDevPlayer(Npgsql.NpgsqlDataSource dataSource, GamePositionOptions options)
|
||||||
{
|
{
|
||||||
using var conn = dataSource.OpenConnection();
|
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>
|
/// <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)
|
public static void SeedDevPlayer(Npgsql.NpgsqlConnection connection, GamePositionOptions options)
|
||||||
{
|
{
|
||||||
var devNorm = NormalizePlayerId(options.DevPlayerId);
|
|
||||||
var p = options.DefaultPosition ?? new DefaultPositionOptions();
|
var p = options.DefaultPosition ?? new DefaultPositionOptions();
|
||||||
using var cmd = new Npgsql.NpgsqlCommand(
|
foreach (var id in GamePlayerSeed.GetSeedPlayerIds(options))
|
||||||
"""
|
{
|
||||||
INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence)
|
var norm = NormalizePlayerId(id);
|
||||||
VALUES (@pid, @x, @y, @z, 0)
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
ON CONFLICT (player_id) DO NOTHING;
|
"""
|
||||||
""",
|
INSERT INTO player_position (player_id, pos_x, pos_y, pos_z, sequence)
|
||||||
connection);
|
VALUES (@pid, @x, @y, @z, 0)
|
||||||
cmd.Parameters.AddWithValue("pid", devNorm);
|
ON CONFLICT (player_id) DO NOTHING;
|
||||||
cmd.Parameters.AddWithValue("x", p.X);
|
""",
|
||||||
cmd.Parameters.AddWithValue("y", p.Y);
|
connection);
|
||||||
cmd.Parameters.AddWithValue("z", p.Z);
|
cmd.Parameters.AddWithValue("pid", norm);
|
||||||
cmd.ExecuteNonQuery();
|
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)
|
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)
|
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);
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@
|
||||||
},
|
},
|
||||||
"Game": {
|
"Game": {
|
||||||
"DevPlayerId": "dev-local-1",
|
"DevPlayerId": "dev-local-1",
|
||||||
|
"FixturePlayerIds": [
|
||||||
|
"neo48-perk-fresh",
|
||||||
|
"neo48-perk-branch"
|
||||||
|
],
|
||||||
"DefaultPosition": {
|
"DefaultPosition": {
|
||||||
"X": -5,
|
"X": -5,
|
||||||
"Y": 0.9,
|
"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.
|
**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/`.
|
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
|
```bash
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue