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
parent
b72201b1f0
commit
ffdbed26f2
|
|
@ -1,6 +1,4 @@
|
|||
vars {
|
||||
baseUrl: http://localhost:5253
|
||||
playerId: dev-local-1
|
||||
neo48PerkFreshPlayerId: neo48-perk-fresh
|
||||
neo48PerkBranchPlayerId: neo48-perk-branch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 >= 0.</summary>
|
||||
bool TrySetSkillXpTotal(string playerId, string skillId, int xp);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -4,5 +4,8 @@
|
|||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Game": {
|
||||
"EnableMasteryFixtureApi": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@
|
|||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
"FixturePlayerIds": [
|
||||
"neo48-perk-fresh",
|
||||
"neo48-perk-branch"
|
||||
],
|
||||
"EnableMasteryFixtureApi": false,
|
||||
"DefaultPosition": {
|
||||
"X": -5,
|
||||
"Y": 0.9,
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue