NEO-48: dev mastery-fixture API for Bruno state setup

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.
pull/83/head
VinPropane 2026-05-17 20:31:26 -04:00
parent b72201b1f0
commit ffdbed26f2
26 changed files with 460 additions and 142 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, int> { ["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<string, int> { ["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<MasteryFixtureResponse>();
Assert.NotNull(resetBody);
Assert.True(resetBody!.Applied);
var progression = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
$"/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<PerkStateSnapshotResponse>(
$"/game/players/{DevPlayer}/perk-state");
Assert.Empty(perkGet!.BranchPicks);
Assert.Equal(HttpStatusCode.OK, deny.StatusCode);
var envelope = await deny.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, envelope.ReasonCode);
}
}

View File

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

View File

@ -32,6 +32,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>
{

View File

@ -16,4 +16,7 @@ public interface IPlayerPerkStateStore
/// <summary>Removes a branch pick (engine rollback when perk unlock fails after pick).</summary>
bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex);
/// <summary>Clears all branch picks and unlocked perks for the player (NEO-48 dev fixture API).</summary>
bool TryResetPerkState(string playerId);
}

View File

@ -13,12 +13,9 @@ 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);
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<GamePositionOptions> o
}
}
/// <inheritdoc />
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<GamePositionOptions> 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<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);

View File

@ -0,0 +1,41 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Dev-only route to reset/set mastery fixture state for Bruno and manual QA (NEO-48).</summary>
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;
}
}

View File

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/mastery-fixture</c> (NEO-48 Bruno/manual QA).</summary>
public sealed class MasteryFixtureRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
/// <summary>When true, clears all branch picks and unlocked perks for the player.</summary>
[JsonPropertyName("resetPerkState")]
public bool ResetPerkState { get; init; }
/// <summary>Absolute XP totals to set per <c>skillId</c> (omitted skills unchanged).</summary>
[JsonPropertyName("skillXp")]
public Dictionary<string, int>? 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; }
}

View File

@ -0,0 +1,39 @@
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Applies dev-only mastery fixture resets (NEO-48).</summary>
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;
}
}

View File

@ -217,6 +217,46 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou
return cmd.ExecuteNonQuery() > 0;
}
/// <inheritdoc />
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(

View File

@ -1,30 +0,0 @@
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,8 +10,8 @@ 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>When true, maps <c>POST …/__dev/mastery-fixture</c> for Bruno/manual QA state setup (also enabled in Development).</summary>
public bool EnableMasteryFixtureApi { get; set; }
/// <summary>World position for the dev player at process start.</summary>
public DefaultPositionOptions DefaultPosition { get; set; } = new();

View File

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

View File

@ -55,23 +55,20 @@ 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();
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)

View File

@ -11,4 +11,7 @@ public interface IPlayerSkillProgressionStore
/// Returns <c>false</c> when the player cannot be written (e.g. in-memory store has no bucket; Postgres player missing from <c>player_position</c>).
/// </summary>
bool TryApplyXpDelta(string playerId, string skillId, int amount, out int previousXp, out int newXp);
/// <summary>Sets absolute XP for one skill (NEO-48 dev fixture API). <paramref name="xp"/> must be &gt;= 0.</summary>
bool TrySetSkillXpTotal(string playerId, string skillId, int xp);
}

View File

@ -14,12 +14,9 @@ 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);
foreach (var id in GamePlayerSeed.GetSeedPlayerIds(o))
{
map[NormalizePlayerId(id)] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
}
map[id] = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
return map;
}
@ -66,6 +63,33 @@ public sealed class InMemoryPlayerSkillProgressionStore(IOptions<GamePositionOpt
}
}
/// <inheritdoc />
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();

View File

@ -98,6 +98,64 @@ public sealed class PostgresPlayerSkillProgressionStore(Npgsql.NpgsqlDataSource
return true;
}
/// <inheritdoc />
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(

View File

@ -36,6 +36,13 @@ app.MapInteractablesWorldApi();
app.MapSkillDefinitionsWorldApi();
app.MapSkillProgressionSnapshotApi();
app.MapPerkStateApi();
if (app.Environment.IsDevelopment() ||
app.Environment.IsEnvironment("Testing") ||
app.Configuration.GetValue<bool>("Game:EnableMasteryFixtureApi"))
{
app.MapMasteryFixtureApi();
}
app.MapTargetingApi();
app.MapHotbarLoadoutApi();
app.MapCooldownSnapshotApi();

View File

@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Game": {
"EnableMasteryFixtureApi": true
}
}

View File

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

View File

@ -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/`.