NEO-47: address code review follow-ups for perk unlock engine
Retroactive path-auto unlock on branch pick, unlock rollback, CanWritePlayer gate, expanded deny tests, gather-hook smoke, decomposition docs, review strikethroughs.pull/81/head
parent
2520a80b6e
commit
0a2a6fe45b
|
|
@ -8,7 +8,7 @@
|
|||
| **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) |
|
||||
| **Linear (Epic 2 project)** | [Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) |
|
||||
| **Stage target** | Pre-production |
|
||||
| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed; **NEO-46** server fail-fast load landed) |
|
||||
| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed; **NEO-46** server load; **NEO-47** perk engine + persistence landed) |
|
||||
|
||||
## Linear backlog (Epic 2 Slice 4 — E2.M3)
|
||||
|
||||
|
|
@ -77,6 +77,10 @@ Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId`
|
|||
|
||||
**Landed ([NEO-46](https://linear.app/neon-sprawl/issue/NEO-46)):** Server boot loads `content/mastery/*_mastery.json` fail-fast under `Game/Mastery/`, mirroring Python CI gates from [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries) plus cross-check against `ISkillDefinitionRegistry`. **`tierIndex`** uniqueness and sequential **1..N** per track are enforced at server boot and in **`validate_content.py`** (protects NEO-47 `PerkState` keys). Read-only **`IMasteryCatalogRegistry`** is registered for NEO-47+.
|
||||
|
||||
## 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 (telemetry in **NEO-49**). **HTTP read/write** for perk state is **NEO-48**.
|
||||
|
||||
## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))
|
||||
|
||||
The **first shipped mastery track** is **`salvage`** only:
|
||||
|
|
|
|||
|
|
@ -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). **Still planned:** perk engine + HTTP ([NEO-47](https://linear.app/neon-sprawl/issue/NEO-47)–[NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)). | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-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). **Still planned:** perk HTTP + Bruno ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)), telemetry hooks ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)). | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-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/` |
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
**Scope:** Branch `NEO-47-perk-unlock-engine-perkstate-persistence` · commits `e297b3a`–`38774a5` vs `main`
|
||||
**Base:** `main`
|
||||
|
||||
**Follow-up:** Addressed review suggestions (retroactive path-auto on branch pick, deny-code tests, unlock rollback, decomposition docs, gather-hook smoke, nits).
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
|
@ -17,8 +19,8 @@ This change delivers server-authoritative perk state (`IPlayerPerkStateStore` in
|
|||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-47-implementation-plan.md`](../plans/NEO-47-implementation-plan.md) | **Matches** — store, engine, V004, hook site, reason codes, acceptance checklist marked complete; tier-1 pick / tier-2 auto-path behavior implemented as adopted kickoff decisions. |
|
||||
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Partially matches** — responsibilities and contracts align; module **Status** / NEO-47 handoff bullets not yet updated (still “NEO-46 landed” only). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Partially matches** — E2.M3 row still lists perk engine as “Still planned”; should note NEO-47 landed after merge. |
|
||||
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-47 handoff + Status updated after follow-up. |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row notes NEO-47 landed after follow-up. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — unlock evaluation and persistence remain server-side; client unchanged until NEO-48. |
|
||||
| [`docs/plans/NEO-46-implementation-plan.md`](../plans/NEO-46-implementation-plan.md) | **Matches** — engine consumes `IMasteryCatalogRegistry` and repo catalog; no loader regression in diff. |
|
||||
|
||||
|
|
@ -30,18 +32,18 @@ None.
|
|||
|
||||
## Suggestions
|
||||
|
||||
1. **Retroactive path-auto unlock after branch pick** — `TrySelectBranch` persists tier-1 picks but does not re-run path-auto evaluation. A player who reaches salvage **level 4+** before choosing tier 1 will not receive tier-2 perks until a **later** level-up, even though `requiredLevel` was already satisfied. NEO-48 branch POST will make this path easy to hit. After a successful pick, call `ReevaluateAfterLevelUp(playerId, skillId, currentLevel)` (level from `GetSkillLevel`) or extract shared “apply path-auto tiers ≤ level” logic. Add a test: XP to 450+, pick tier 1, assert tier-2 perk without another grant.
|
||||
2. **Deny-code test gaps vs plan** — Plan table lists `unknown_track`, `unknown_tier`, and `player_not_found`. Tests cover `level_too_low`, `branch_already_chosen`, `unknown_branch`, and `tier_branch_not_selectable` but not the three above. Small engine unit tests (bad `skillId`, bad `tierIndex`, unknown player id on in-memory store without seed) would lock NEO-48 HTTP mapping.
|
||||
3. **`UnlockPerks` store failure** — When `TryAddUnlockedPerks` returns `false`, the method returns an empty event list while `TrySelectBranch` may have already committed the branch pick. Harmless for prototype tier 1 (empty `perkIds`), but worth surfacing `player_not_found` on the outcome or rolling back the pick before NEO-48/expanded catalogs add perks on pick tiers.
|
||||
4. **Decomposition doc refresh** — Mirror NEO-46 follow-up: add NEO-47 handoff to `E2_M3_MasteryAndPerkUnlocks.md` (engine + stores, no HTTP) and update the E2.M3 row in `documentation_and_implementation_alignment.md`.
|
||||
5. **Gather-hook integration smoke** — Plan mentions `InteractionApi` grant path; implementation wires `perkUnlockEngine` correctly, but only `POST …/skill-progression` is smoke-tested for level-4 unlock. One interact test (or manual QA note in NEO-48) would close the loop for NEO-41 parity.
|
||||
1. ~~**Retroactive path-auto unlock after branch pick** — `TrySelectBranch` persists tier-1 picks but does not re-run path-auto evaluation. A player who reaches salvage **level 4+** before choosing tier 1 will not receive tier-2 perks until a **later** level-up, even though `requiredLevel` was already satisfied. NEO-48 branch POST will make this path easy to hit. After a successful pick, call `ReevaluateAfterLevelUp(playerId, skillId, currentLevel)` (level from `GetSkillLevel`) or extract shared “apply path-auto tiers ≤ level” logic. Add a test: XP to 450+, pick tier 1, assert tier-2 perk without another grant.~~ **Done.** `ApplyPathAutoTiersAtOrBelowLevel` shared by pick + level-up; test `TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant`.
|
||||
2. ~~**Deny-code test gaps vs plan** — Plan table lists `unknown_track`, `unknown_tier`, and `player_not_found`. Tests cover `level_too_low`, `branch_already_chosen`, `unknown_branch`, and `tier_branch_not_selectable` but not the three above. Small engine unit tests (bad `skillId`, bad `tierIndex`, unknown player id on in-memory store without seed) would lock NEO-48 HTTP mapping.~~ **Done.** Added `TrySelectBranch_WhenUnknownTrack_*`, `WhenUnknownTier_*`, `WhenPlayerNotInStore_*`.
|
||||
3. ~~**`UnlockPerks` store failure** — When `TryAddUnlockedPerks` returns `false`, the method returns an empty event list while `TrySelectBranch` may have already committed the branch pick. Harmless for prototype tier 1 (empty `perkIds`), but worth surfacing `player_not_found` on the outcome or rolling back the pick before NEO-48/expanded catalogs add perks on pick tiers.~~ **Done.** `TryUnlockPerks` returns false → `TryRemoveBranchPick` rollback + `player_not_found` deny.
|
||||
4. ~~**Decomposition doc refresh** — Mirror NEO-46 follow-up: add NEO-47 handoff to `E2_M3_MasteryAndPerkUnlocks.md` (engine + stores, no HTTP) and update the E2.M3 row in `documentation_and_implementation_alignment.md`.~~ **Done.**
|
||||
5. ~~**Gather-hook integration smoke** — Plan mentions `InteractionApi` grant path; implementation wires `perkUnlockEngine` correctly, but only `POST …/skill-progression` is smoke-tested for level-4 unlock. One interact test (or manual QA note in NEO-48) would close the loop for NEO-41 parity.~~ **Done.** `PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: `PerkUnlockEngineTests.CreateEngine` opens a `IServiceScope` without disposing; use `using var scope = …` or resolve from a single test scope.
|
||||
- Nit: `ReevaluateAfterLevelUp` calls `perkStore.GetSnapshot` inside the per-tier loop; a single snapshot per invocation is enough.
|
||||
- ~~Nit: `PerkUnlockEngineTests.CreateEngine` opens a `IServiceScope` without disposing; use `using var scope = …` or resolve from a single test scope.~~ **Done.** Resolve singletons from `factory.Services`.
|
||||
- ~~Nit: `ReevaluateAfterLevelUp` calls `perkStore.GetSnapshot` inside the per-tier loop; a single snapshot per invocation is enough.~~ **Done.** `ApplyPathAutoTiersAtOrBelowLevel` refreshes snapshot only after successful unlock batches.
|
||||
- Nit: Empty `skillId` / `branchId` on `TrySelectBranch` maps to `unknown_track` rather than a dedicated validation code — acceptable if NEO-48 trims inputs first.
|
||||
- Nit: Plan risk “loader comment cross-ref to engine tier rule” not added; optional comment on `MasteryCatalogLoader` or `TierRequiresExplicitPick` pointing to `PerkUnlockEngine`.
|
||||
- ~~Nit: Plan risk “loader comment cross-ref to engine tier rule” not added; optional comment on `MasteryCatalogLoader` or `TierRequiresExplicitPick` pointing to `PerkUnlockEngine`.~~ **Done.** Cross-ref on `TryGetTierIndexGateError` and `PerkUnlockEngine` class summary.
|
||||
|
||||
## Verification
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.Mastery;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.World;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Mastery;
|
||||
|
|
@ -9,6 +12,7 @@ namespace NeonSprawl.Server.Tests.Game.Mastery;
|
|||
public sealed class PerkUnlockEngineTests
|
||||
{
|
||||
private const string DevPlayer = "dev-local-1";
|
||||
private const string UnknownPlayer = "no-such-player-neo-47";
|
||||
private const string SalvageSkill = "salvage";
|
||||
private const string ScrapBranch = "scrap_efficiency";
|
||||
private const string BulkBranch = "bulk_haul";
|
||||
|
|
@ -16,14 +20,11 @@ public sealed class PerkUnlockEngineTests
|
|||
private const string BulkPerk = "salvage_bulk_haul_1";
|
||||
|
||||
private static (PerkUnlockEngine Engine, IPlayerSkillProgressionStore XpStore, IPlayerPerkStateStore PerkStore) CreateEngine(
|
||||
InMemoryWebApplicationFactory factory)
|
||||
{
|
||||
var scope = factory.Services.CreateScope();
|
||||
return (
|
||||
scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>(),
|
||||
scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>());
|
||||
}
|
||||
InMemoryWebApplicationFactory factory) =>
|
||||
(
|
||||
factory.Services.GetRequiredService<PerkUnlockEngine>(),
|
||||
factory.Services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||
factory.Services.GetRequiredService<IPlayerPerkStateStore>());
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_AtSalvageLevel2_ShouldRecordPick_WithoutUnlockingTier2Perks()
|
||||
|
|
@ -47,6 +48,28 @@ public sealed class PerkUnlockEngineTests
|
|||
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
var (engine, xpStore, perkStore) = CreateEngine(factory);
|
||||
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
|
||||
|
||||
// Act
|
||||
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
|
||||
|
||||
// Assert
|
||||
Assert.True(outcome.Success);
|
||||
var perk = Assert.Single(outcome.Events);
|
||||
Assert.Equal(ScrapPerk, perk.PerkId);
|
||||
Assert.Equal(PerkUnlockSource.LevelUp, perk.Source);
|
||||
var snap = perkStore.GetSnapshot(DevPlayer);
|
||||
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
|
||||
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_WhenLevelTooLow_ShouldDenyWithLevelTooLow()
|
||||
{
|
||||
|
|
@ -63,6 +86,56 @@ public sealed class PerkUnlockEngineTests
|
|||
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_WhenUnknownTrack_ShouldDenyWithUnknownTrack()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
var (engine, xpStore, _) = CreateEngine(factory);
|
||||
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
|
||||
|
||||
// Act
|
||||
var outcome = engine.TrySelectBranch(DevPlayer, "refine", tierIndex: 1, ScrapBranch);
|
||||
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PerkUnlockReasonCodes.UnknownTrack, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_WhenUnknownTier_ShouldDenyWithUnknownTier()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
var (engine, xpStore, _) = CreateEngine(factory);
|
||||
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
|
||||
|
||||
// Act
|
||||
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 99, ScrapBranch);
|
||||
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PerkUnlockReasonCodes.UnknownTier, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_WhenPlayerNotInStore_ShouldDenyWithPlayerNotFound()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
var (engine, _, _) = CreateEngine(factory);
|
||||
|
||||
// Act
|
||||
var outcome = engine.TrySelectBranch(UnknownPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
|
||||
|
||||
// Assert
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(PerkUnlockReasonCodes.PlayerNotFound, outcome.ReasonCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySelectBranch_WhenBranchAlreadyChosen_ShouldDenyWithBranchAlreadyChosen()
|
||||
{
|
||||
|
|
@ -125,17 +198,14 @@ public sealed class PerkUnlockEngineTests
|
|||
var (engine, xpStore, perkStore) = CreateEngine(factory);
|
||||
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
|
||||
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
|
||||
var snapAfterPick = perkStore.GetSnapshot(DevPlayer);
|
||||
Assert.Contains(ScrapPerk, snapAfterPick.UnlockedPerkIds);
|
||||
|
||||
// Act
|
||||
var outcome = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
|
||||
|
||||
// Assert
|
||||
var perk = Assert.Single(outcome.Events);
|
||||
Assert.Equal(ScrapPerk, perk.PerkId);
|
||||
Assert.Equal(PerkUnlockSource.LevelUp, perk.Source);
|
||||
var snap = perkStore.GetSnapshot(DevPlayer);
|
||||
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
|
||||
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
|
||||
Assert.Empty(outcome.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -164,10 +234,7 @@ public sealed class PerkUnlockEngineTests
|
|||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var engine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||
var perkStore = scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
|
||||
var (engine, xpStore, perkStore) = CreateEngine(factory);
|
||||
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
|
||||
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
|
||||
var grant = new SkillProgressionGrantRequest
|
||||
|
|
@ -186,4 +253,35 @@ public sealed class PerkUnlockEngineTests
|
|||
var snap = perkStore.GetSnapshot(DevPlayer);
|
||||
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook()
|
||||
{
|
||||
// Arrange
|
||||
await using var factory = new InMemoryWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
var (engine, xpStore, perkStore) = CreateEngine(factory);
|
||||
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 440, out _, out _));
|
||||
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
|
||||
var move = new MoveCommandRequest
|
||||
{
|
||||
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
|
||||
Target = new PositionVector { X = 10, Y = 0.9, Z = -6 },
|
||||
};
|
||||
await client.PostAsJsonAsync($"/game/players/{DevPlayer}/move", move);
|
||||
|
||||
// Act
|
||||
var interact = await client.PostAsJsonAsync(
|
||||
$"/game/players/{DevPlayer}/interact",
|
||||
new InteractionRequest
|
||||
{
|
||||
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
|
||||
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.True(interact.IsSuccessStatusCode);
|
||||
var snap = perkStore.GetSnapshot(DevPlayer);
|
||||
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ public interface IPlayerPerkStateStore
|
|||
{
|
||||
PerkStateSnapshot GetSnapshot(string playerId);
|
||||
|
||||
/// <summary>Whether <paramref name="playerId"/> can receive perk state writes (mirrors skill progression store bucket rules).</summary>
|
||||
bool CanWritePlayer(string playerId);
|
||||
|
||||
/// <summary>Fails when pick already exists for <c>(skillId, tierIndex)</c> or player cannot be written.</summary>
|
||||
bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId);
|
||||
|
||||
/// <summary>Adds perks not already unlocked; fails when player cannot be written.</summary>
|
||||
bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds);
|
||||
|
||||
/// <summary>Removes a branch pick (engine rollback when perk unlock fails after pick).</summary>
|
||||
bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
|
|||
return map;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanWritePlayer(string playerId)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
return key.Length > 0 && byPlayer.ContainsKey(key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PerkStateSnapshot GetSnapshot(string playerId)
|
||||
{
|
||||
|
|
@ -84,6 +91,22 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
|
||||
{
|
||||
var key = NormalizePlayerId(playerId);
|
||||
var sid = skillId.Trim();
|
||||
if (key.Length == 0 || sid.Length == 0 || tierIndex < 1 || !byPlayer.TryGetValue(key, out var inner))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||
{
|
||||
return inner.RemoveBranchPick(sid, tierIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizePlayerId(string? playerId)
|
||||
{
|
||||
var t = playerId?.Trim();
|
||||
|
|
@ -117,6 +140,9 @@ public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> o
|
|||
|
||||
public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId);
|
||||
|
||||
public bool RemoveBranchPick(string skillId, int tierIndex) =>
|
||||
branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex);
|
||||
|
||||
public PerkStateSnapshot ToSnapshot()
|
||||
{
|
||||
var bySkill = new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);
|
||||
|
|
|
|||
|
|
@ -289,7 +289,10 @@ public static class MasteryCatalogLoader
|
|||
return new MasteryCatalog(masteryDirectory, tracksBySkillId, perksById, jsonFiles.Length);
|
||||
}
|
||||
|
||||
/// <summary>Server-only gate: unique tierIndex values must be exactly 1..N (NEO-46 kickoff).</summary>
|
||||
/// <summary>
|
||||
/// Server-only gate: unique tierIndex values must be exactly 1..N (NEO-46 kickoff).
|
||||
/// Stable <c>tierIndex</c> keys <see cref="PerkUnlockEngine"/> branch picks and path-auto tier evaluation.
|
||||
/// </summary>
|
||||
internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList<int> tierIndexValues)
|
||||
{
|
||||
if (tierIndexValues.Count == 0)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace NeonSprawl.Server.Game.Mastery;
|
|||
/// <summary>
|
||||
/// Server-authoritative perk unlock evaluation from skill level + branch picks (NEO-47).
|
||||
/// Tier rule: all branches have empty <c>perkIds</c> ⇒ explicit pick tier; otherwise path-auto when a prior pick tier exists.
|
||||
/// Keep in sync with <see cref="MasteryCatalogLoader"/> tier shape — see <see cref="TierRequiresExplicitPick"/>.
|
||||
/// </summary>
|
||||
public sealed class PerkUnlockEngine(
|
||||
IMasteryCatalogRegistry catalog,
|
||||
|
|
@ -12,7 +13,7 @@ public sealed class PerkUnlockEngine(
|
|||
IPlayerSkillProgressionStore xpStore,
|
||||
ISkillLevelCurve levelCurve)
|
||||
{
|
||||
/// <summary>Selects a branch for a pick tier; unlocks any perks on that branch for the tier.</summary>
|
||||
/// <summary>Selects a branch for a pick tier; unlocks tier perks and retroactive path-auto tiers at current level.</summary>
|
||||
public PerkBranchSelectOutcome TrySelectBranch(
|
||||
string playerId,
|
||||
string skillIdRaw,
|
||||
|
|
@ -48,6 +49,11 @@ public sealed class PerkUnlockEngine(
|
|||
return Deny(snapshot, PerkUnlockReasonCodes.UnknownBranch);
|
||||
}
|
||||
|
||||
if (!perkStore.CanWritePlayer(playerId))
|
||||
{
|
||||
return Deny(snapshot, PerkUnlockReasonCodes.PlayerNotFound);
|
||||
}
|
||||
|
||||
var level = GetSkillLevel(playerId, track.SkillId);
|
||||
if (level < tier.RequiredLevel)
|
||||
{
|
||||
|
|
@ -65,52 +71,40 @@ public sealed class PerkUnlockEngine(
|
|||
}
|
||||
|
||||
var branch = tier.Branches.First(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
|
||||
var events = UnlockPerks(playerId, track.SkillId, tier.TierIndex, branch.BranchId, branch.PerkIds, PerkUnlockSource.BranchPick);
|
||||
snapshot = perkStore.GetSnapshot(playerId);
|
||||
return new PerkBranchSelectOutcome(true, null, events, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Unlocks path-auto tier perks after skill level-up (idempotent).</summary>
|
||||
public PerkReevaluationOutcome ReevaluateAfterLevelUp(string playerId, string skillIdRaw, int newLevel)
|
||||
{
|
||||
var allEvents = new List<PerkUnlockEvent>();
|
||||
var skillId = skillIdRaw.Trim();
|
||||
if (skillId.Length == 0 || !catalog.TryGetTrack(skillId, out var track))
|
||||
{
|
||||
return new PerkReevaluationOutcome(allEvents, perkStore.GetSnapshot(playerId));
|
||||
}
|
||||
|
||||
foreach (var tier in track.Tiers.OrderBy(static t => t.TierIndex))
|
||||
{
|
||||
if (tier.RequiredLevel > newLevel || !TierIsPathAuto(tier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pathBranchId = GetPathBranchId(perkStore.GetSnapshot(playerId), track, tier);
|
||||
if (pathBranchId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var branch = tier.Branches.FirstOrDefault(b =>
|
||||
string.Equals(b.BranchId, pathBranchId, StringComparison.Ordinal));
|
||||
if (branch is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var batch = UnlockPerks(
|
||||
if (!TryUnlockPerks(
|
||||
playerId,
|
||||
track.SkillId,
|
||||
tier.TierIndex,
|
||||
branch.BranchId,
|
||||
branch.PerkIds,
|
||||
PerkUnlockSource.LevelUp);
|
||||
allEvents.AddRange(batch);
|
||||
PerkUnlockSource.BranchPick,
|
||||
snapshot,
|
||||
out var pickEvents))
|
||||
{
|
||||
_ = perkStore.TryRemoveBranchPick(playerId, track.SkillId, tier.TierIndex);
|
||||
return Deny(perkStore.GetSnapshot(playerId), PerkUnlockReasonCodes.PlayerNotFound);
|
||||
}
|
||||
|
||||
return new PerkReevaluationOutcome(allEvents, perkStore.GetSnapshot(playerId));
|
||||
allEvents.AddRange(pickEvents);
|
||||
snapshot = perkStore.GetSnapshot(playerId);
|
||||
allEvents.AddRange(ApplyPathAutoTiersAtOrBelowLevel(playerId, track, level, snapshot));
|
||||
snapshot = perkStore.GetSnapshot(playerId);
|
||||
return new PerkBranchSelectOutcome(true, null, allEvents, snapshot);
|
||||
}
|
||||
|
||||
/// <summary>Unlocks path-auto tier perks after skill level-up (idempotent).</summary>
|
||||
public PerkReevaluationOutcome ReevaluateAfterLevelUp(string playerId, string skillIdRaw, int newLevel)
|
||||
{
|
||||
var skillId = skillIdRaw.Trim();
|
||||
if (skillId.Length == 0 || !catalog.TryGetTrack(skillId, out var track))
|
||||
{
|
||||
return new PerkReevaluationOutcome([], perkStore.GetSnapshot(playerId));
|
||||
}
|
||||
|
||||
var snapshot = perkStore.GetSnapshot(playerId);
|
||||
var events = ApplyPathAutoTiersAtOrBelowLevel(playerId, track, newLevel, snapshot);
|
||||
return new PerkReevaluationOutcome(events, perkStore.GetSnapshot(playerId));
|
||||
}
|
||||
|
||||
private PerkBranchSelectOutcome Deny(PerkStateSnapshot snapshot, string reason) =>
|
||||
|
|
@ -161,20 +155,69 @@ public sealed class PerkUnlockEngine(
|
|||
return snapshot.TryGetBranchPick(track.SkillId, anchor.TierIndex, out var bid) ? bid : null;
|
||||
}
|
||||
|
||||
private IReadOnlyList<PerkUnlockEvent> UnlockPerks(
|
||||
private List<PerkUnlockEvent> ApplyPathAutoTiersAtOrBelowLevel(
|
||||
string playerId,
|
||||
MasteryTrackRow track,
|
||||
int maxLevel,
|
||||
PerkStateSnapshot snapshot)
|
||||
{
|
||||
var allEvents = new List<PerkUnlockEvent>();
|
||||
foreach (var tier in track.Tiers.OrderBy(static t => t.TierIndex))
|
||||
{
|
||||
if (tier.RequiredLevel > maxLevel || !TierIsPathAuto(tier))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pathBranchId = GetPathBranchId(snapshot, track, tier);
|
||||
if (pathBranchId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var branch = tier.Branches.FirstOrDefault(b =>
|
||||
string.Equals(b.BranchId, pathBranchId, StringComparison.Ordinal));
|
||||
if (branch is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryUnlockPerks(
|
||||
playerId,
|
||||
track.SkillId,
|
||||
tier.TierIndex,
|
||||
branch.BranchId,
|
||||
branch.PerkIds,
|
||||
PerkUnlockSource.LevelUp,
|
||||
snapshot,
|
||||
out var batch))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
allEvents.AddRange(batch);
|
||||
snapshot = perkStore.GetSnapshot(playerId);
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
private bool TryUnlockPerks(
|
||||
string playerId,
|
||||
string skillId,
|
||||
int tierIndex,
|
||||
string branchId,
|
||||
IReadOnlyList<string> perkIds,
|
||||
PerkUnlockSource source)
|
||||
PerkUnlockSource source,
|
||||
PerkStateSnapshot snapshot,
|
||||
out IReadOnlyList<PerkUnlockEvent> events)
|
||||
{
|
||||
events = [];
|
||||
if (perkIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
return true;
|
||||
}
|
||||
|
||||
var snapshot = perkStore.GetSnapshot(playerId);
|
||||
var newIds = new List<string>();
|
||||
foreach (var perkId in perkIds)
|
||||
{
|
||||
|
|
@ -186,16 +229,17 @@ public sealed class PerkUnlockEngine(
|
|||
|
||||
if (newIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!perkStore.TryAddUnlockedPerks(playerId, newIds))
|
||||
{
|
||||
return [];
|
||||
return false;
|
||||
}
|
||||
|
||||
return newIds
|
||||
events = newIds
|
||||
.Select(pid => new PerkUnlockEvent(playerId, pid, skillId, tierIndex, branchId, source))
|
||||
.ToList();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,24 @@ namespace NeonSprawl.Server.Game.Mastery;
|
|||
/// <summary>PostgreSQL-backed perk state keyed by normalized player id (NEO-47).</summary>
|
||||
public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSource) : IPlayerPerkStateStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool CanWritePlayer(string playerId)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
if (norm.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
return cmd.ExecuteScalar() is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public PerkStateSnapshot GetSnapshot(string playerId)
|
||||
{
|
||||
|
|
@ -175,6 +193,30 @@ public sealed class PostgresPlayerPerkStateStore(Npgsql.NpgsqlDataSource dataSou
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
|
||||
{
|
||||
var norm = NormalizePlayerId(playerId);
|
||||
var sid = skillId.Trim();
|
||||
if (norm.Length == 0 || sid.Length == 0 || tierIndex < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||
using var conn = dataSource.OpenConnection();
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
"""
|
||||
DELETE FROM player_mastery_branch_pick
|
||||
WHERE player_id = @pid AND skill_id = @sid AND tier_index = @tier;
|
||||
""",
|
||||
conn);
|
||||
cmd.Parameters.AddWithValue("pid", norm);
|
||||
cmd.Parameters.AddWithValue("sid", sid);
|
||||
cmd.Parameters.AddWithValue("tier", tierIndex);
|
||||
return cmd.ExecuteNonQuery() > 0;
|
||||
}
|
||||
|
||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction tx)
|
||||
{
|
||||
using var cmd = new Npgsql.NpgsqlCommand(
|
||||
|
|
|
|||
Loading…
Reference in New Issue