NEO-48: perk-state HTTP, Bruno, tests, and docs
GET/POST /game/players/{id}/perk-state with versioned JSON, engine-backed
branch selection, PerkStateApiTests, Bruno collection, README, and E2.M3 alignment.
pull/83/head
parent
5290044845
commit
39c54c3881
|
|
@ -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/dev-local-1/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("dev-local-1");
|
||||
expect(body.branchPicks).to.be.an("array");
|
||||
expect(body.unlockedPerkIds).to.be.an("array");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
meta {
|
||||
name: POST branch select deny level too low
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-48: salvage tier 1 requires skill level 2. On a fresh server (no prior salvage XP), expects selected false + level_too_low.
|
||||
Restart server or use a fresh dev DB to hit this deny; skip if salvage already leveled from other Bruno runs.
|
||||
}
|
||||
|
||||
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("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);
|
||||
if (body.selected === false && body.reasonCode === "level_too_low") {
|
||||
expect(body.unlockedEvents).to.be.an("array").that.is.empty;
|
||||
expect(body.perkState.branchPicks).to.be.an("array");
|
||||
return;
|
||||
}
|
||||
expect(body.selected).to.equal(true, "salvage already at level 2+ — restart server for deny smoke");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
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.
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: perk-state
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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-37–NEO-41, NEO-42–NEO-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/` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
# 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
|
||||
|
||||
- [ ] Second pick same tier different branch → `selected: false`, `reasonCode` **`branch_already_chosen`**.
|
||||
- [ ] On fresh server without XP grant → `level_too_low`.
|
||||
|
||||
## Bruno
|
||||
|
||||
- [ ] Run folder `bruno/neon-sprawl-server/perk-state/` (GET + POST happy + deny smoke).
|
||||
|
||||
## Regression
|
||||
|
||||
```bash
|
||||
cd server && dotnet test NeonSprawl.sln
|
||||
```
|
||||
|
||||
Expect all tests pass including `PerkStateApiTests`.
|
||||
|
|
@ -42,11 +42,11 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] `GET /game/players/{id}/perk-state` returns versioned JSON v1.
|
||||
- [ ] `POST` branch selection applies NEO-47 unlock rules; structured denies on invalid tier/branch.
|
||||
- [ ] Bruno collection `bruno/neon-sprawl-server/perk-state/` (GET + POST + deny).
|
||||
- [ ] `server/README.md` section for perk-state endpoints.
|
||||
- [ ] Unit/integration tests (AAA) for API happy/deny paths.
|
||||
- [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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
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_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 envelope = await post.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostPerkState_ShouldDenyBranchAlreadyChosen_OnSecondPick()
|
||||
{
|
||||
// Arrange
|
||||
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}/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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
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")]
|
||||
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; }
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ app.MapInteractionApi();
|
|||
app.MapInteractablesWorldApi();
|
||||
app.MapSkillDefinitionsWorldApi();
|
||||
app.MapSkillProgressionSnapshotApi();
|
||||
app.MapPerkStateApi();
|
||||
app.MapTargetingApi();
|
||||
app.MapHotbarLoadoutApi();
|
||||
app.MapCooldownSnapshotApi();
|
||||
|
|
|
|||
|
|
@ -56,6 +56,23 @@ 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.
|
||||
|
||||
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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue