Merge pull request #83 from ViPro-Technologies/NEO-48-perk-state-get-branch-selection-post-bruno

NEO-48: perk state GET + branch selection POST + Bruno
pull/84/head
VinPropane 2026-05-17 21:08:11 -04:00 committed by GitHub
commit 1ad04f05cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1742 additions and 4 deletions

View File

@ -0,0 +1,28 @@
meta {
name: GET perk state
type: http
seq: 1
}
docs {
NEO-48: per-player perk state read model (branch picks + unlocked perk ids).
Empty state until branch picks; pair with skill-progression grant + POST branch select.
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
body: none
auth: none
}
tests {
test("returns 200 JSON schema v1", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
expect(body.branchPicks).to.be.an("array");
expect(body.unlockedPerkIds).to.be.an("array");
});
}

View File

@ -0,0 +1,71 @@
meta {
name: POST branch select deny branch already chosen
type: http
seq: 4
}
docs {
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("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: 100 },
},
jsonHeaders,
);
if (reset.status !== 200 || reset.data?.applied !== true) {
throw new Error(`mastery fixture reset failed: ${reset.status} ${JSON.stringify(reset.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 {
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"tierIndex": 1,
"branchId": "bulk_haul"
}
}
tests {
test("denies second pick with branch_already_chosen", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.selected).to.equal(false);
expect(body.reasonCode).to.equal("branch_already_chosen");
expect(body.unlockedEvents).to.be.an("array").that.is.empty;
const salvage = body.perkState.branchPicks.find((t) => t.skillId === "salvage");
expect(salvage).to.be.an("object");
const pick = salvage.picks.find((p) => p.tierIndex === 1);
expect(pick.branchId).to.equal("scrap_efficiency");
});
}

View File

@ -0,0 +1,55 @@
meta {
name: POST branch select deny level too low
type: http
seq: 3
}
docs {
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/{{playerId}}/perk-state
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"tierIndex": 1,
"branchId": "scrap_efficiency"
}
}
tests {
test("denies with level_too_low when salvage below gate", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
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").that.is.empty;
});
}

View File

@ -0,0 +1,46 @@
meta {
name: POST branch select tier1
type: http
seq: 2
}
docs {
NEO-48: tier-1 salvage branch pick. Run skill-progression grant (100+ XP for level 2) first if this denies level_too_low.
On success, reasonCode is omitted from JSON (not null); test expects undefined.
}
post {
url: {{baseUrl}}/game/players/dev-local-1/perk-state
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"tierIndex": 1,
"branchId": "scrap_efficiency"
}
}
tests {
test("selects branch or denies with structured envelope", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.selected).to.be.a("boolean");
expect(body.perkState).to.be.an("object");
expect(body.perkState.schemaVersion).to.equal(1);
expect(body.unlockedEvents).to.be.an("array");
if (body.selected) {
expect(body.reasonCode).to.be.undefined;
const salvage = body.perkState.branchPicks.find((t) => t.skillId === "salvage");
expect(salvage).to.be.an("object");
const pick = salvage.picks.find((p) => p.tierIndex === 1);
expect(pick.branchId).to.equal("scrap_efficiency");
} else {
expect(body.reasonCode).to.be.a("string");
}
});
}

View File

@ -0,0 +1,8 @@
meta {
name: perk-state
}
docs {
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).
Run deny requests (seq 34) before happy-path POST tier1 if you need a clean dev-local-1; deny pre-requests reset shared player state.
}

View File

@ -8,6 +8,7 @@ docs {
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change).
NEO-47: on level-up, server re-evaluates mastery perk unlocks internally (no JSON change until NEO-48 perk-state HTTP).
Success responses include reasonCode: null (stable since NEO-38); perk-state POST omits reasonCode on success instead.
E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links.
}
@ -32,6 +33,7 @@ tests {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.granted).to.equal(true);
expect(body.reasonCode).to.equal(null);
expect(body.progression).to.be.an("object");
expect(body.progression.schemaVersion).to.equal(1);
expect(body.progression.playerId).to.equal("dev-local-1");

View File

@ -79,7 +79,11 @@ Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId`
## NEO-47 handoff (perk unlock engine + PerkState persistence)
**Landed ([NEO-47](https://linear.app/neon-sprawl/issue/NEO-47)):** `IPlayerPerkStateStore` (in-memory + Postgres **`V004`**) persists branch picks and unlocked `perkId`s. **`PerkUnlockEngine`** evaluates tier-1 explicit branch picks and **path-auto** tier-2 unlocks from the tier-1 `branchId` when skill level meets gates (no second pick at tier 2 for prototype salvage). Level-up re-evaluation runs inside **`SkillProgressionGrantOperations`** after XP grants (HTTP POST, gather interact, mission/refine helpers). Stable deny **`reasonCode`** values for invalid picks. Internal **`PerkUnlockEvent`** returned from engine methods. **HTTP read/write** for perk state is **NEO-48**.
**Landed ([NEO-47](https://linear.app/neon-sprawl/issue/NEO-47)):** `IPlayerPerkStateStore` (in-memory + Postgres **`V004`**) persists branch picks and unlocked `perkId`s. **`PerkUnlockEngine`** evaluates tier-1 explicit branch picks and **path-auto** tier-2 unlocks from the tier-1 `branchId` when skill level meets gates (no second pick at tier 2 for prototype salvage). Level-up re-evaluation runs inside **`SkillProgressionGrantOperations`** after XP grants (HTTP POST, gather interact, mission/refine helpers). Stable deny **`reasonCode`** values for invalid picks. Internal **`PerkUnlockEvent`** returned from engine methods.
## NEO-48 handoff (perk state HTTP + Bruno)
**Landed ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)):** Versioned **`GET`/`POST /game/players/{id}/perk-state`** — read model + branch selection via **`PerkUnlockEngine`**; structured **`selected`** / **`reasonCode`** / **`unlockedEvents`** envelopes. Bruno: `bruno/neon-sprawl-server/perk-state/`; [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); plan [NEO-48](../../plans/NEO-48-implementation-plan.md); manual QA [`NEO-48.md`](../../manual-qa/NEO-48.md).
## NEO-49 handoff (perk_unlock telemetry hook sites)

View File

@ -51,7 +51,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) |
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/``MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **Still planned:** perk HTTP + Bruno ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)). | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/``MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |

View File

@ -36,7 +36,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Slice 3 integration: [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)[NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) landed; **NEO-44** (gig XP, Epic 5) remains backlog. Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
**E2.M3 note:** Epic 2 Slice 4 — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49); label **`E2.M3`**. **NEO-45 landed:** prototype **`salvage`** mastery catalog + schema + CI. **NEO-46 landed:** fail-fast server mastery catalog load. **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants. **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook in `PerkUnlockEngine.TryUnlockPerks`. **Still planned:** perk HTTP + Bruno ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)). Flagship track; see [E2_M3_MasteryAndPerkUnlocks.md](E2_M3_MasteryAndPerkUnlocks.md), [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md), and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E2.M3 row.
**E2.M3 note:** Epic 2 Slice 4 — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49); label **`E2.M3`**. **NEO-45 landed:** prototype **`salvage`** mastery catalog + schema + CI. **NEO-46 landed:** fail-fast server mastery catalog load. **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants. **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook in `PerkUnlockEngine.TryUnlockPerks`. **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** + Bruno `perk-state/`. Flagship track; see [E2_M3_MasteryAndPerkUnlocks.md](E2_M3_MasteryAndPerkUnlocks.md), [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md), and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E2.M3 row.
### Epic 3 — Crafting Economy

View File

@ -0,0 +1,69 @@
# Manual QA — NEO-48 (perk state GET + branch selection POST)
Reference: [implementation plan](../plans/NEO-48-implementation-plan.md), [NEO-47 plan](../plans/NEO-47-implementation-plan.md) (unlock engine).
## Preconditions
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`).
- Dev player `dev-local-1` exists in position state (default in-memory / seeded Postgres).
## GET empty state
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/perk-state" | jq .
```
- [ ] HTTP **200**; `schemaVersion` **1**; `branchPicks` **[]**; `unlockedPerkIds` **[]** (fresh server).
## POST tier-1 pick (happy path)
1. Grant salvage XP to reach level **2** (prototype curve: **100** total XP):
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":100,"sourceKind":"activity"}' | jq .
```
2. Select tier-1 branch:
```bash
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 .
```
- [ ] `selected: true`; `perkState.branchPicks` contains salvage tier 1 `scrap_efficiency`; `unlockedEvents` empty at tier 1.
- [ ] Repeat **GET** — same pick visible.
## POST deny (mastery fixture reset)
Requires **`Game:EnableMasteryFixtureApi`** (on in Development) or Development environment.
```bash
# 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
```
- [ ] Fixture reset + branch POST → `level_too_low`.
- [ ] Fixture reset with `skillXp.salvage: 100`, pick `scrap_efficiency`, POST `bulk_haul`**`branch_already_chosen`**.
## Bruno
- [ ] Run folder `bruno/neon-sprawl-server/perk-state/` (GET + POST happy + deny smoke).
- [ ] Run **`Post branch select deny branch already chosen`** (self-contained; pre-request grant + first pick).
## Regression
```bash
cd server && dotnet test NeonSprawl.sln
```
Expect all tests pass including `PerkStateApiTests`.

View File

@ -0,0 +1,135 @@
# NEO-48 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-48 |
| **Title** | E2.M3: Perk state GET + branch selection POST + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-48/e2m3-perk-state-get-branch-selection-post-bruno |
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
| **Branch** | `NEO-48-perk-state-get-branch-selection-post-bruno` |
| **Blocked by** | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47) — **Done** on `main` (`PerkUnlockEngine`, `IPlayerPerkStateStore`, `PerkUnlockReasonCodes`). |
## Kickoff clarifications
| Topic | Question | Resolution |
|--------|----------|------------|
| **POST URL** | Same resource path as GET vs dedicated sub-path? | **User:** **`POST /game/players/{id}/perk-state`** (same as GET; mirrors skill-progression / hotbar-loadout). |
| **POST success body** | Include unlock events from `PerkUnlockEngine`? | **User:** **Yes**`selected: true` + full **`perkState`** snapshot + **`unlockedEvents[]`** (mirrors NEO-38 `levelUps` on grant). |
| **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
**Goal:** Versioned HTTP **GET** read model and **POST** branch selection for per-player perk state, delegating all rules to **`PerkUnlockEngine`** from NEO-47. Match NEO-37/NEO-38 versioning, player gate, and structured deny patterns.
**In scope (from Linear):**
- **`GET /game/players/{id}/perk-state`** — `schemaVersion` **1** JSON: branch picks + unlocked perk ids.
- **`POST /game/players/{id}/perk-state`** — commit one branch pick; structured success/deny; stable **`reasonCode`** values from engine.
- Bruno under **`bruno/neon-sprawl-server/perk-state/`** (GET + POST happy + deny).
- **`server/README.md`** section; API integration tests (AAA).
- **`docs/manual-qa/NEO-48.md`** during implementation (repo convention for HTTP stories).
**Out of scope (from Linear):**
- Godot client HUD.
- Applying perk `effectKind` in gather/craft.
- New telemetry hooks (NEO-49 complete).
- Respec; gig perks.
## Acceptance criteria checklist
- [x] `GET /game/players/{id}/perk-state` returns versioned JSON v1.
- [x] `POST` branch selection applies NEO-47 unlock rules; structured denies on invalid tier/branch.
- [x] Bruno collection `bruno/neon-sprawl-server/perk-state/` (GET + POST + deny).
- [x] `server/README.md` section for perk-state endpoints.
- [x] Unit/integration tests (AAA) for API happy/deny paths.
## Technical approach
1. **Routes (`Game/Mastery/PerkStateApi.cs`):**
- **`GET /game/players/{id}/perk-state`** — trim id; **404** if `!IPositionStateStore.TryGetPosition`; else **200** + `PerkStateSnapshotResponse` built from `IPlayerPerkStateStore.GetSnapshot`.
- **`POST /game/players/{id}/perk-state`** — body `PerkBranchSelectRequest` (`schemaVersion` **1**, `skillId`, `tierIndex`, `branchId`). **400** if body null or `schemaVersion != 1`. **404** same player gate. Call **`perkUnlockEngine.TrySelectBranch(trimmedId, skillId, tierIndex, branchId)`**; map outcome to `PerkBranchSelectResponse`.
2. **Snapshot builder:** `PerkStateApi.BuildSnapshot(playerId, IPlayerPerkStateStore)` maps `PerkStateSnapshot` → JSON DTO:
- **`branchPicks`:** one entry per skill with any picks; each **`picks`** row has `tierIndex` + `branchId`.
- **`unlockedPerkIds`:** sorted ordinal list from set.
- Preserve **`schemaVersion` 1**, **`playerId`**.
3. **POST success (`selected: true`):**
- **`perkState`:** same shape as GET body (without re-wrapping `playerId` if shared type — use shared `PerkStateSnapshotResponse` or embed identical fields).
- **`unlockedEvents`:** map each `PerkUnlockEvent``{ perkId, skillId, tierIndex, branchId, source }` where **`source`** is JSON string **`branch_pick`** | **`level_up`** (snake_case; maps `PerkUnlockSource.BranchPick` / `LevelUp`). Empty array when tier-1 pick unlocks no perks yet.
4. **POST deny (`selected: false`):**
- **`reasonCode`:** pass through engine `ReasonCode` (`unknown_track`, `unknown_tier`, `unknown_branch`, `level_too_low`, `branch_already_chosen`, `tier_branch_not_selectable`, `player_not_found`).
- **`perkState`:** authoritative snapshot from outcome (unchanged on deny except where engine returns updated snapshot — use `outcome.Snapshot` always).
- **`unlockedEvents`:** empty array on deny.
5. **Wire-up:** `app.MapPerkStateApi()` in **`Program.cs`** after mastery/perk DI is available (`PerkUnlockEngine` already registered in `AddPerkStateStore`). No new store or engine logic in this story.
6. **Bruno / README / manual QA:**
- Sequence: optional **`POST …/skill-progression`** grant to reach salvage level 2, then **POST perk-state** tier-1 pick, **GET** verifies pick; deny example: second pick or `level_too_low` without grant.
- Document paths, schema fields, reason codes, and link to this plan + **`docs/manual-qa/NEO-48.md`**.
7. **Module doc (implementation batch):** Update [E2_M3](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) NEO-47 handoff line to note HTTP landed; refresh [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E2.M3 row when complete.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Mastery/PerkStateDtos.cs` | `PerkStateSnapshotResponse`, `PerkBranchPickTrackJson`, `PerkBranchPickRowJson`, `PerkBranchSelectRequest`, `PerkBranchSelectResponse`, `PerkUnlockedEventJson`. |
| `server/NeonSprawl.Server/Game/Mastery/PerkStateApi.cs` | `MapPerkStateApi`, `BuildSnapshot`, GET/POST handlers. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs` | Dev fixture request/response (`resetPerkState`, `skillXp`). |
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs` | `POST …/__dev/mastery-fixture` (gated). |
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs` | Batch XP apply + perk reset with XP rollback on reset failure. |
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs` | In-memory host: GET empty defaults; POST tier-1 happy after XP grant; deny `level_too_low` / `branch_already_chosen`; GET reflects POST; **404** / **400** gates. |
| `bruno/neon-sprawl-server/perk-state/folder.bru` | Collection folder metadata. |
| `bruno/neon-sprawl-server/perk-state/Get perk state.bru` | GET smoke + schema assertions. |
| `bruno/neon-sprawl-server/perk-state/Post branch select tier1.bru` | Happy path (docs: grant salvage XP first if needed). |
| `bruno/neon-sprawl-server/perk-state/Post branch select deny level too low.bru` | Deny without level gate. |
| `docs/manual-qa/NEO-48.md` | Curl/Bruno checklist for GET/POST/deny. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapPerkStateApi()`** and conditional **`MapMasteryFixtureApi()`**. |
| `server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs` | **`TrySetSkillXpTotal`** / **`TrySetSkillXpTotals`** for dev fixture. |
| `server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs` | **`TryResetPerkState`** for dev fixture. |
| `server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs` | Fixture XP set implementations. |
| `server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs` | Fixture XP set implementations. |
| `server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs` | **`TryResetPerkState`**. |
| `server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs` | **`TryResetPerkState`**. |
| `server/README.md` | New **Perk state (NEO-48)** section: GET/POST paths, schema v1, `selected`/`reasonCode`/`unlockedEvents`, reason code table (link `PerkUnlockReasonCodes`), Bruno path, manual QA link. |
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Note NEO-48 HTTP landed (replace “HTTP is NEO-48” pending wording in NEO-47 handoff). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: NEO-48 perk-state HTTP. |
| `docs/decomposition/modules/module_dependency_register.md` | E2.M3 note: NEO-48 planned → landed when shipped. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `PerkStateApiTests.cs` | **AAA** integration via `InMemoryWebApplicationFactory`: **GET** returns v1 empty state for dev player; **POST** after `POST …/skill-progression` grant (100 XP → level 2) selects `scrap_efficiency` tier 1 → `selected: true`, pick in **GET**; **POST** deny `level_too_low` without grant; **POST** deny `branch_already_chosen` on second pick; **404** unknown player; **400** bad `schemaVersion`. Optional: **POST** at level 4+ includes non-empty **`unlockedEvents`** when tier-2 perk unlocks on same request (path-auto). |
| `MasteryFixtureApiTests.cs` | Fixture route disabled → **404**; **400** null body / bad schema / negative XP; **404** unknown player; reset after progressed state → **`level_too_low`** on next branch POST. |
| Existing `PerkUnlockEngineTests.cs` | No changes required — engine rules remain covered; API tests are thin HTTP mapping. |
Bruno + **`docs/manual-qa/NEO-48.md`** supplement automated coverage. Postgres persistence of picks is already covered by **`PerkStatePersistenceIntegrationTests`** (NEO-47); no new Postgres API test unless a regression gap appears during implementation.
## Open questions / risks
- **Field name `selected` vs `granted`:** Chosen to distinguish perk branch pick from XP grant; document clearly in README to avoid client confusion.
- **Large `unlockedEvents` batches:** Prototype catalog emits at most a handful per request; no pagination needed.
- **Concurrent POST picks:** Same as NEO-47 store semantics; no new transactional requirement for this story.
## Decisions
| Decision | Rationale |
|----------|-----------|
| POST on same path as GET | User choice; matches skill-progression / hotbar. |
| Include `unlockedEvents` on success | User choice; parallels `levelUps`; supports client/QA without parsing perk id diffs. |
| Branch picks as sorted arrays in JSON | Stable serialization; consistent with skill-progression “unordered by contract, sorted for bytes” pattern. |
| Reuse NEO-47 `reasonCode` strings verbatim | README + tests + Bruno share one vocabulary with engine. |

View File

@ -0,0 +1,67 @@
# Code review (re-review) — NEO-48 testing refactor + mastery fixture API
**Date:** 2026-05-17
**Scope:** Branch `NEO-48-perk-state-get-branch-selection-post-bruno` · commits `3ba8300``ffdbed2` (delta since [first review](2026-05-17-NEO-48.md)) · full branch vs `main` for merge readiness
**Base:** `main`
**Prior review:** [`2026-05-17-NEO-48.md`](2026-05-17-NEO-48.md) (perk-state HTTP + initial Bruno/tests)
**Follow-up:** R2 suggestions 13 and nits 1 + 3 + 4 addressed in `aba2644`; re-verified at **195** passing server tests.
## Verdict
**Approve**
## Summary
Follow-up work closes the first-review Bruno/test gaps and replaces a short-lived **fixture-player seeding** experiment with a cleaner **dev-only mastery fixture API**. **`POST /game/players/{id}/__dev/mastery-fixture`** (gated to Development, Testing, or `Game:EnableMasteryFixtureApi`) resets perk state and sets absolute skill XP without going through grant/level-up hooks—giving deterministic Bruno deny smokes on shared `dev-local-1`. **`PerkStateApiTests`** adds null-body **400** and HTTP deny smoke for `unknown_track` / `unknown_tier` / `unknown_branch`; **`MasteryFixtureApiTests`** verifies Production+flag-off returns **404** and that reset + XP zero yields **`level_too_low`** on the next branch POST. Bruno deny requests use `script:pre-request` + `axios` against the fixture endpoint; `{{playerId}}` from `Local.bru` is used consistently on deny flows. **191** server tests pass. Merge risk remains low; the new surface is explicitly non-production and tested for disable behavior.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-48-implementation-plan.md`](../plans/NEO-48-implementation-plan.md) | **Matches** — perk-state HTTP/Bruno acceptance unchanged; kickoff documents fixture API; **Files to add/modify** lists `MasteryFixture*` and store fixture methods. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-48 handoff covers HTTP only; dev fixture correctly omitted from module contracts. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — production perk rules still flow through `PerkUnlockEngine`; fixture bypasses grant path by design (dev/QA only). |
| [`docs/manual-qa/NEO-48.md`](../manual-qa/NEO-48.md) | **Matches** — fixture curl examples + Bruno deny checklist updated. |
| [`server/README.md`](../../server/README.md) | **Matches** — Perk state section documents fixture API, gating, and **404** when disabled. |
| Prior [`2026-05-17-NEO-48.md`](2026-05-17-NEO-48.md) | **Superseded for merge** by this re-review for the testing-refactor delta; first-review suggestions 13 marked done there remain valid. |
## Blocking issues
None.
## Suggestions
1. ~~**Atomic fixture apply** — `MasteryFixtureOperations.TryApply` can leave mixed state if `resetPerkState` succeeds and a later `skillXp` entry fails (or an earlier XP write succeeds before a later one fails). For a dev endpoint this is unlikely with Brunos fixed payloads, but consider applying XP first then reset, a single transaction on Postgres, or returning **409**/**500** with a note when partial apply is detected.~~ **Done.** Batch **`TrySetSkillXpTotals`** then **`resetPerkState`**; XP rollback via previous snapshot if reset fails; **400** for invalid `skillXp` before writes.
2. ~~**Fixture API test coverage** — Add small tests for **400** (null body / bad `schemaVersion`) and **404** (unknown player, negative XP in `skillXp`) on `__dev/mastery-fixture` to mirror perk-state gate tests.~~ **Done.** Four new tests in `MasteryFixtureApiTests`.
3. ~~**Document fixture vs grant semantics** — README could note that **`skillXp` on the fixture sets absolute totals without running `SkillProgressionGrantOperations` / perk level-up reevaluation** (intentional for deny setup; use normal grant API when testing unlock side effects).~~ **Done.** README Perk state section.
## Nits
- ~~Nit: `Get perk state.bru` still asserts `playerId === "dev-local-1"` while deny requests use `{{playerId}}` from the environment—harmless if `Local.bru` keeps the default; align assertions for copy-paste environments.~~ **Done.** GET uses `{{playerId}}` in URL and assertion.
- ~~Nit: `TryResetPerkState` / `TrySetSkillXpTotal` live on production store interfaces—acceptable for prototype; if the surface grows, consider a dev-only `IMasteryFixtureStore` facade later.~~ **Deferred.** Acceptable for E2.M3 prototype; revisit if more dev-only store methods accumulate.
- ~~Nit: Bruno deny flows mutate shared `dev-local-1`; folder `docs` explain fixture pre-requests—run deny requests before happy-path smokes or accept order sensitivity in manual runs.~~ **Done.** Folder `docs` note on run order.
- ~~Nit: `MasteryFixtureApiTests.PostMasteryFixture_ShouldResetSalvageXpAndPerkState_ThenLevelTooLowDenies` performs reset and deny POST both under **Act**; valid for an integration scenario, but heavy **Arrange** (grant + pick) could move to a private helper like `PerkStateApiTests.GrantSalvageLevel2Async`.~~ **Done.** `ArrangeSalvageProgressedWithTier1PickAsync`.
## Delta since first review (testing refactor)
| Area | Change |
|------|--------|
| **Bruno** | `Post branch select deny branch already chosen.bru`; `level_too_low` uses fixture pre-request (no skip-on-already-leveled); `{{playerId}}` + `baseUrl` resolution fixed. |
| **Server** | `MasteryFixtureApi` / `MasteryFixtureOperations` / DTOs; `Program.cs` conditional map; `EnableMasteryFixtureApi` in appsettings; store `TryResetPerkState` + `TrySetSkillXpTotal` (in-memory + Postgres). |
| **Tests** | `MasteryFixtureApiTests` (2); `PerkStateApiTests` +4 deny/null-body; `InMemoryWebApplicationFactory` enables fixture flag for Testing. |
| **Reverted approach** | `GamePlayerSeed` / extra fixture player ids (commit `b72201b`) removed in favor of fixture API (`ffdbed2`)—simpler ops story. |
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkState|MasteryFixture"
# Full server suite (195 tests after aba2644)
dotnet test NeonSprawl.sln
# Bruno (Development server, EnableMasteryFixtureApi on)
# bruno/neon-sprawl-server/perk-state/
```
Confirm deny Bruno requests pass on a fresh dev server; confirm `POST …/__dev/mastery-fixture` returns **404** when `ASPNETCORE_ENVIRONMENT=Production` and `Game:EnableMasteryFixtureApi=false`.

View File

@ -0,0 +1,59 @@
# Code review — NEO-48 perk state GET + branch selection POST
**Date:** 2026-05-17
**Scope:** Branch `NEO-48-perk-state-get-branch-selection-post-bruno` · commits `5290044``39c54c3` vs `main`
**Base:** `main`
**Follow-up:** Suggestions 13 and nit 1 addressed on branch; r2 follow-up (`aba2644`) closed fixture/testing items — see [`2026-05-17-NEO-48-r2.md`](2026-05-17-NEO-48-r2.md).
## Verdict
**Approve**
## Summary
NEO-48 adds thin HTTP mapping for versioned **`GET`/`POST /game/players/{id}/perk-state`**: read model (`branchPicks`, `unlockedPerkIds`), branch selection via **`PerkUnlockEngine.TrySelectBranch`**, and structured **`selected`** / **`reasonCode`** / **`perkState`** / **`unlockedEvents`** envelopes mirroring NEO-38 grant patterns. Eight integration tests cover 404/400 gates, happy tier-1 pick after XP grant, `level_too_low` / `branch_already_chosen` denies, and path-auto **`unlockedEvents`** at level 4+. Bruno folder, README, manual QA, and E2.M3 decomposition rows align with the implementation plan. **185** server tests pass locally. Low merge risk; no engine or persistence logic duplicated in the API layer.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-48-implementation-plan.md`](../plans/NEO-48-implementation-plan.md) | **Matches** — same-path GET/POST, schema v1, deny envelope, `unlockedEvents` on success, position-state 404 gate, Bruno/README/tests/manual QA; acceptance checklist complete. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-48 handoff section; HTTP responsibility listed under module responsibilities. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row cites NEO-48 landed + README/Bruno links. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E2.M3 note includes NEO-48 perk-state HTTP. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — branch picks and unlock evaluation remain server-authoritative; no client changes (out of scope). |
| [`docs/plans/NEO-47-implementation-plan.md`](../plans/NEO-47-implementation-plan.md) | **Matches** — API delegates to engine/store; no duplicate unlock rules; telemetry stays in `TryUnlockPerks` (NEO-49). |
Register/tracking: E2.M3 module doc, alignment row, and register note updated in implementation commit.
## Blocking issues
None.
## Suggestions
1. ~~**Bruno deny for `branch_already_chosen`** — Plan and manual QA describe a second POST to the same tier with a different branch; automated tests cover it (`PostPerkState_ShouldDenyBranchAlreadyChosen_OnSecondPick`), but Bruno only ships `Post branch select deny level too low.bru`. A fourth request (grant + first pick + conflicting second pick) would make the collection self-contained for CI/manual runs without relying on manual QA alone.~~ **Done.** Added `Post branch select deny branch already chosen.bru` with `script:pre-request` grant + first pick via `bru.sendRequest`.
2. ~~**Optional thin HTTP deny smoke** — Engine tests lock `unknown_track`, `unknown_tier`, and `unknown_branch`; one API test each (bad `skillId`, bad `tierIndex`, bad `branchId` on dev player after level gate) would guard JSON field mapping if DTOs change. Non-blocking given `PerkUnlockEngineTests` coverage.~~ **Done.** `PostPerkState_ShouldDenyUnknownTrack_*`, `UnknownTier_*`, `UnknownBranch_*` in `PerkStateApiTests`.
3. ~~**POST null body** — Handlers return **400** for `body is null` (same as skill-progression). Neither API suite asserts empty-body **400** today; add a one-liner in `PerkStateApiTests` if you want parity documentation in tests.~~ **Done.** `PostPerkState_ShouldReturnBadRequest_WhenBodyNull`.
## Nits
- ~~Nit: `Post branch select deny level too low.bru` intentionally passes when salvage is already leveled (restart note) — acceptable for shared dev player; document in Bruno folder `docs` if operators hit false greens often.~~ **Done.** Folder `docs` block in `perk-state/folder.bru`.
- ~~Nit: Path-auto tier-2 unlocks surfaced in **`unlockedEvents`** use **`source: level_up`** (not `branch_pick`) — consistent with NEO-47/NEO-49 notes; clients/telemetry should treat `source` as unlock channel, not HTTP verb.~~ **Deferred.** Documented in NEO-47/NEO-49; no API change required for NEO-48.
- ~~Nit: `PostPerkState_ShouldSelectTier1_AndGetReflectsPick_AfterSalvageLevel2` issues a follow-up **GET** inside **Assert** for end-to-end verification — valid AAA (verification in Assert); grant status checks in **Arrange** match other API tests in the repo.~~ **Deferred.** Accepted integration-test pattern; no change.
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkStateApi"
# Full server suite (195 tests at final verification)
dotnet test NeonSprawl.sln
# Manual / Bruno
# docs/manual-qa/NEO-48.md
# bruno/neon-sprawl-server/perk-state/
```
Confirm **GET** empty state, **POST** tier-1 happy path after salvage grant (100 XP), **POST** deny codes, and **GET** reflects persisted pick; optional path-auto **`unlockedEvents`** at level 4+ (500 total XP before pick).

View File

@ -0,0 +1,178 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
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";
private const string FixturePath = $"/game/players/{DevPlayer}/__dev/mastery-fixture";
private static MasteryFixtureRequest ValidFixture(
bool resetPerkState = true,
Dictionary<string, int>? skillXp = null) =>
new MasteryFixtureRequest
{
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion,
ResetPerkState = resetPerkState,
SkillXp = skillXp ?? new Dictionary<string, int> { ["salvage"] = 0 },
};
private static async Task ArrangeSalvageProgressedWithTier1PickAsync(HttpClient client)
{
var grant = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = "salvage",
Amount = 100,
SourceKind = "activity",
});
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
var pick = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
new PerkBranchSelectRequest
{
SchemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion,
SkillId = "salvage",
TierIndex = 1,
BranchId = "scrap_efficiency",
});
Assert.Equal(HttpStatusCode.OK, pick.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
{
// Arrange
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(FixturePath, ValidFixture());
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
FixturePath,
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = ValidFixture();
request = new MasteryFixtureRequest
{
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion + 99,
ResetPerkState = request.ResetPerkState,
SkillXp = request.SkillXp,
};
// Act
var response = await client.PostAsJsonAsync(FixturePath, request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenSkillXpNegative()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
FixturePath,
ValidFixture(skillXp: new Dictionary<string, int> { ["salvage"] = -1 }));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/__dev/mastery-fixture",
ValidFixture());
// 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();
await ArrangeSalvageProgressedWithTier1PickAsync(client);
// Act
var reset = await client.PostAsJsonAsync(FixturePath, ValidFixture());
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

@ -0,0 +1,311 @@
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public sealed class PerkStateApiTests
{
private const string DevPlayer = "dev-local-1";
private static PerkBranchSelectRequest SelectBranch(
string skillId,
int tierIndex,
string branchId,
int schemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion) =>
new PerkBranchSelectRequest
{
SchemaVersion = schemaVersion,
SkillId = skillId,
TierIndex = tierIndex,
BranchId = branchId,
};
private static SkillProgressionGrantRequest SalvageGrant(int amount) =>
new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = "salvage",
Amount = amount,
SourceKind = "activity",
};
[Fact]
public async Task GetPerkState_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/perk-state");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetPerkState_ShouldReturnSchemaV1_WithEmptyState_ForDevPlayer()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync($"/game/players/{DevPlayer}/perk-state");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PerkStateSnapshotResponse>();
Assert.NotNull(body);
Assert.Equal(PerkStateSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal(DevPlayer, body.PlayerId);
Assert.Empty(body.BranchPicks);
Assert.Empty(body.UnlockedPerkIds);
}
[Fact]
public async Task PostPerkState_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
$"/game/players/{DevPlayer}/perk-state",
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = SelectBranch("salvage", 1, "scrap_efficiency", PerkBranchSelectRequest.CurrentSchemaVersion + 42);
// Act
var response = await client.PostAsJsonAsync($"/game/players/{DevPlayer}/perk-state", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyLevelTooLow_WhenSalvageBelowTierGate()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/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);
Assert.Empty(envelope.UnlockedEvents);
Assert.Empty(envelope.PerkState.BranchPicks);
}
[Fact]
public async Task PostPerkState_ShouldSelectTier1_AndGetReflectsPick_AfterSalvageLevel2()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var grant = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(100));
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
// Act
var post = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var json = await post.Content.ReadAsStringAsync();
using (var doc = JsonDocument.Parse(json))
{
Assert.False(doc.RootElement.TryGetProperty("reasonCode", out _));
}
var envelope = JsonSerializer.Deserialize<PerkBranchSelectResponse>(json);
Assert.NotNull(envelope);
Assert.True(envelope!.Selected);
Assert.Null(envelope.ReasonCode);
Assert.Empty(envelope.UnlockedEvents);
var track = Assert.Single(envelope.PerkState.BranchPicks);
Assert.Equal("salvage", track.SkillId);
var pick = Assert.Single(track.Picks);
Assert.Equal(1, pick.TierIndex);
Assert.Equal("scrap_efficiency", pick.BranchId);
Assert.Empty(envelope.PerkState.UnlockedPerkIds);
var get = await client.GetFromJsonAsync<PerkStateSnapshotResponse>($"/game/players/{DevPlayer}/perk-state");
Assert.NotNull(get);
var getTrack = Assert.Single(get!.BranchPicks);
Assert.Equal("scrap_efficiency", Assert.Single(getTrack.Picks).BranchId);
}
private static async Task GrantSalvageLevel2Async(HttpClient client)
{
var grant = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(100));
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyUnknownTrack_WhenSkillIdInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("refine", 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.UnknownTrack, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyUnknownTier_WhenTierIndexInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 99, "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.UnknownTier, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyUnknownBranch_WhenBranchIdInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "not_a_branch"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.UnknownBranch, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyBranchAlreadyChosen_OnSecondPick()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"))).StatusCode);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "bulk_haul"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.BranchAlreadyChosen, envelope.ReasonCode);
Assert.Equal("scrap_efficiency", Assert.Single(Assert.Single(envelope.PerkState.BranchPicks).Picks).BranchId);
}
[Fact]
public async Task PostPerkState_ShouldEmitUnlockedEvents_WhenTier2PerkUnlocksAtLevel4()
{
// Arrange — 100 + 400 XP → level 4 before tier-1 pick; path-auto tier 2 on same POST
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(100))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(400))).StatusCode);
// Act
var post = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var envelope = await post.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Selected);
var unlocked = Assert.Single(envelope.UnlockedEvents);
Assert.Equal("salvage_scrap_efficiency_1", unlocked.PerkId);
Assert.Equal("salvage", unlocked.SkillId);
Assert.Equal(2, unlocked.TierIndex);
Assert.Equal("scrap_efficiency", unlocked.BranchId);
Assert.Equal("level_up", unlocked.Source);
Assert.Contains("salvage_scrap_efficiency_1", envelope.PerkState.UnlockedPerkIds);
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public sealed class InMemoryPlayerSkillProgressionStoreTests
{
[Fact]
public void TrySetSkillXpTotal_WhenXpZeroAndSkillAbsent_ShouldReturnTrue()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var store = factory.Services.GetRequiredService<IPlayerSkillProgressionStore>();
// Act
var applied = store.TrySetSkillXpTotal("dev-local-1", "intrusion", xp: 0);
// Assert
Assert.True(applied);
Assert.False(store.GetXpTotals("dev-local-1").ContainsKey("intrusion"));
}
}

View File

@ -1,6 +1,7 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using NeonSprawl.Server.Game.Skills;
using Xunit;
@ -120,6 +121,26 @@ public sealed class SkillProgressionGrantApiTests
Assert.Equal(SkillProgressionSnapshotApi.ReasonInvalidAmount, envelope.ReasonCode);
}
[Fact]
public async Task PostSkillProgression_ShouldSerializeReasonCodeNull_OnSuccess()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("salvage", amount: 10));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("reasonCode", out var reasonCode));
Assert.Equal(JsonValueKind.Null, reasonCode.ValueKind);
}
[Fact]
public async Task PostSkillProgression_ShouldApplyGrant_AndGetMatches_WhenTrainerAllowedForRefine()
{

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

@ -107,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();
@ -143,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,46 @@
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();
}
if (!MasteryFixtureOperations.TryNormalizeSkillXp(body.SkillXp, out _))
{
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,70 @@
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Applies dev-only mastery fixture resets (NEO-48).</summary>
public static class MasteryFixtureOperations
{
/// <summary>Validates <paramref name="skillXp"/> entries; returns false when any key is blank or XP is negative.</summary>
public static bool TryNormalizeSkillXp(
IReadOnlyDictionary<string, int>? skillXp,
out Dictionary<string, int> normalized)
{
normalized = new Dictionary<string, int>(StringComparer.Ordinal);
if (skillXp is null || skillXp.Count == 0)
{
return true;
}
foreach (var (skillId, xp) in skillXp)
{
var sid = skillId.Trim();
if (sid.Length == 0 || xp < 0)
{
normalized.Clear();
return false;
}
normalized[sid] = xp;
}
return true;
}
/// <summary>Applies skill XP (batch, then perk reset). Rolls back XP when perk reset fails after XP writes.</summary>
public static bool TryApply(
string playerId,
MasteryFixtureRequest body,
IPlayerPerkStateStore perkStore,
IPlayerSkillProgressionStore xpStore)
{
if (!TryNormalizeSkillXp(body.SkillXp, out var skillXp))
{
return false;
}
var previousXp = new Dictionary<string, int>(xpStore.GetXpTotals(playerId), StringComparer.Ordinal);
if (skillXp.Count > 0 && !xpStore.TrySetSkillXpTotals(playerId, skillXp))
{
return false;
}
if (!body.ResetPerkState)
{
return true;
}
if (perkStore.TryResetPerkState(playerId))
{
return true;
}
if (skillXp.Count > 0)
{
_ = xpStore.TrySetSkillXpTotals(playerId, previousXp);
}
return false;
}
}

View File

@ -0,0 +1,124 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>
/// Maps <c>GET</c>/<c>POST /game/players/{{id}}/perk-state</c> — read snapshot and branch selection (NEO-48).
/// </summary>
public static class PerkStateApi
{
public static WebApplication MapPerkStateApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/perk-state",
(string id, IPositionStateStore positions, IPlayerPerkStateStore perkStore) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
return Results.Json(BuildSnapshot(trimmedId, perkStore));
});
app.MapPost(
"/game/players/{id}/perk-state",
(string id, PerkBranchSelectRequest? body, IPositionStateStore positions, PerkUnlockEngine perkUnlockEngine) =>
{
if (body is null || body.SchemaVersion != PerkBranchSelectRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
var outcome = perkUnlockEngine.TrySelectBranch(
trimmedId,
body.SkillId,
body.TierIndex,
body.BranchId);
var perkState = BuildSnapshot(trimmedId, outcome.Snapshot);
if (!outcome.Success)
{
return Results.Json(
new PerkBranchSelectResponse
{
Selected = false,
ReasonCode = outcome.ReasonCode,
PerkState = perkState,
UnlockedEvents = [],
});
}
return Results.Json(
new PerkBranchSelectResponse
{
Selected = true,
PerkState = perkState,
UnlockedEvents = MapEvents(outcome.Events),
});
});
return app;
}
internal static PerkStateSnapshotResponse BuildSnapshot(string playerId, IPlayerPerkStateStore store) =>
BuildSnapshot(playerId, store.GetSnapshot(playerId));
internal static PerkStateSnapshotResponse BuildSnapshot(string playerId, PerkStateSnapshot snapshot)
{
var branchPicks = new List<PerkBranchPickTrackJson>(snapshot.BranchPicksBySkillId.Count);
foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
{
var picks = tiers
.OrderBy(static kv => kv.Key)
.Select(static kv => new PerkBranchPickRowJson { TierIndex = kv.Key, BranchId = kv.Value })
.ToList();
branchPicks.Add(new PerkBranchPickTrackJson { SkillId = skillId, Picks = picks });
}
var unlocked = snapshot.UnlockedPerkIds.OrderBy(static id => id, StringComparer.Ordinal).ToList();
return new PerkStateSnapshotResponse
{
PlayerId = playerId,
BranchPicks = branchPicks,
UnlockedPerkIds = unlocked,
};
}
private static IReadOnlyList<PerkUnlockedEventJson> MapEvents(IReadOnlyList<PerkUnlockEvent> events)
{
if (events.Count == 0)
{
return [];
}
var mapped = new List<PerkUnlockedEventJson>(events.Count);
foreach (var e in events)
{
mapped.Add(
new PerkUnlockedEventJson
{
PerkId = e.PerkId,
SkillId = e.SkillId,
TierIndex = e.TierIndex,
BranchId = e.BranchId,
Source = e.Source switch
{
PerkUnlockSource.BranchPick => "branch_pick",
PerkUnlockSource.LevelUp => "level_up",
_ => throw new InvalidOperationException($"Unexpected perk unlock source: {e.Source}"),
},
});
}
return mapped;
}
}

View File

@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>JSON body for <c>GET /game/players/{{id}}/perk-state</c> (NEO-48).</summary>
public sealed class PerkStateSnapshotResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Branch picks per skill. Consumers key by <see cref="PerkBranchPickTrackJson.SkillId"/> — array order is not part of the contract.</summary>
[JsonPropertyName("branchPicks")]
public required IReadOnlyList<PerkBranchPickTrackJson> BranchPicks { get; init; }
[JsonPropertyName("unlockedPerkIds")]
public required IReadOnlyList<string> UnlockedPerkIds { get; init; }
}
public sealed class PerkBranchPickTrackJson
{
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("picks")]
public required IReadOnlyList<PerkBranchPickRowJson> Picks { get; init; }
}
public sealed class PerkBranchPickRowJson
{
[JsonPropertyName("tierIndex")]
public int TierIndex { get; init; }
[JsonPropertyName("branchId")]
public required string BranchId { get; init; }
}
/// <summary>POST body for branch selection on <c>POST …/perk-state</c> (NEO-48).</summary>
public sealed class PerkBranchSelectRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("tierIndex")]
public int TierIndex { get; init; }
[JsonPropertyName("branchId")]
public required string BranchId { get; init; }
}
/// <summary>POST responses for branch selection — success (<see cref="Selected"/>) or structured deny (<see cref="ReasonCode"/>).</summary>
public sealed class PerkBranchSelectResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("selected")]
public bool Selected { get; init; }
[JsonPropertyName("reasonCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasonCode { get; init; }
[JsonPropertyName("perkState")]
public required PerkStateSnapshotResponse PerkState { get; init; }
[JsonPropertyName("unlockedEvents")]
public required IReadOnlyList<PerkUnlockedEventJson> UnlockedEvents { get; init; }
}
public sealed class PerkUnlockedEventJson
{
[JsonPropertyName("perkId")]
public required string PerkId { get; init; }
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("tierIndex")]
public int TierIndex { get; init; }
[JsonPropertyName("branchId")]
public string? BranchId { get; init; }
[JsonPropertyName("source")]
public required string Source { get; init; }
}

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

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

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

View File

@ -11,4 +11,10 @@ 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);
/// <summary>Sets absolute XP for multiple skills atomically (NEO-48 dev fixture API).</summary>
bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals);
}

View File

@ -63,6 +63,70 @@ 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)
{
inner.TryRemove(sid, out _);
return true;
}
inner[sid] = xp;
return true;
}
}
/// <inheritdoc />
public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
foreach (var (skillId, xp) in skillXpTotals)
{
if (skillId.Trim().Length == 0 || xp < 0)
{
return false;
}
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
foreach (var (skillId, xp) in skillXpTotals)
{
var sid = skillId.Trim();
if (xp == 0)
{
inner.TryRemove(sid, out _);
}
else
{
inner[sid] = xp;
}
}
return true;
}
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();

View File

@ -98,6 +98,128 @@ 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;
}
/// <inheritdoc />
public bool TrySetSkillXpTotals(string playerId, IReadOnlyDictionary<string, int> skillXpTotals)
{
var norm = NormalizePlayerId(playerId);
if (norm.Length == 0)
{
return false;
}
foreach (var (skillId, xp) in skillXpTotals)
{
if (skillId.Trim().Length == 0 || xp < 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;
}
foreach (var (skillId, xp) in skillXpTotals)
{
var sid = skillId.Trim();
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

@ -35,6 +35,14 @@ app.MapInteractionApi();
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,6 +12,7 @@
},
"Game": {
"DevPlayerId": "dev-local-1",
"EnableMasteryFixtureApi": false,
"DefaultPosition": {
"X": -5,
"Y": 0.9,

View File

@ -56,6 +56,25 @@ On success, **Information** logs include the resolved mastery directory, track c
**NEO-49:** comment-only **telemetry hook site** for future catalog event **`perk_unlock`** in **`PerkUnlockEngine.TryUnlockPerks`** when new perk ids are persisted (one emit per unlocked perk; **`TODO(E9.M1)`** — no production ingest). Manual QA: [`docs/manual-qa/NEO-49.md`](../../docs/manual-qa/NEO-49.md); plan: [NEO-49 implementation plan](../../docs/plans/NEO-49-implementation-plan.md).
## Perk state (NEO-48)
**`GET /game/players/{id}/perk-state`** returns versioned JSON (`schemaVersion` **1**, **`playerId`**, **`branchPicks`**, **`unlockedPerkIds`**). **`branchPicks`** is an array of `{ skillId, picks: [{ tierIndex, branchId }] }` — key by **`skillId`**; array order is not part of the contract. **`unlockedPerkIds`** lists perk ids currently unlocked for the player.
**`POST /game/players/{id}/perk-state`** commits one mastery branch pick (`schemaVersion` **1**, **`skillId`**, **`tierIndex`**, **`branchId`**). Delegates to **`PerkUnlockEngine.TrySelectBranch`** (NEO-47). **404** when the player is unknown to position state; **400** when **`schemaVersion`** is missing or not **1**. Persistence uses **`IPlayerPerkStateStore`** + Flyway **`V004`** when Postgres is configured (same [NEO-29-style](#position-persistence-neo-8) split as skill progression).
**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.
**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`; omitted skills unchanged). Fixture writes set totals **directly** — they do **not** run **`SkillProgressionGrantOperations`** or perk **level-up reevaluation**; use normal **`POST …/skill-progression`** when testing unlock side effects. Apply order: batch **`skillXp`** first, then **`resetPerkState`** (XP rolls back if reset fails). **400** for bad schema or invalid **`skillXp`**; **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/`.
```bash
curl -sS -i "http://localhost:5253/game/players/dev-local-1/perk-state"
curl -sS -i -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"}'
```
## Skill definitions (NEO-36)
**`GET /game/world/skill-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`skills`**) backed by **`ISkillDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-36 implementation plan](../../docs/plans/NEO-36-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-36.md`](../../docs/manual-qa/NEO-36.md); Bruno: `bruno/neon-sprawl-server/skill-definitions/`.