Merge pull request #81 from ViPro-Technologies/NEO-47-perk-unlock-engine-perkstate-persistence
NEO-47: Perk unlock engine + PerkState persistencepull/84/head
commit
01a02da923
|
|
@ -7,6 +7,7 @@ meta {
|
||||||
docs {
|
docs {
|
||||||
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
|
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
|
||||||
NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change).
|
NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change).
|
||||||
|
NEO-47: on level-up, server re-evaluates mastery perk unlocks internally (no JSON change until NEO-48 perk-state HTTP).
|
||||||
E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links.
|
E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
| **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) |
|
| **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) |
|
| **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 |
|
| **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)
|
## 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+.
|
**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))
|
## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))
|
||||||
|
|
||||||
The **first shipped mastery track** is **`salvage`** only:
|
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.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) |
|
| 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.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 |
|
| 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/` |
|
| 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/` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# NEO-47 — Implementation plan
|
||||||
|
|
||||||
|
## Story reference
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|--------|--------|
|
||||||
|
| **Key** | NEO-47 |
|
||||||
|
| **Title** | E2.M3: Perk unlock engine + PerkState persistence |
|
||||||
|
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence |
|
||||||
|
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
|
||||||
|
| **Branch** | `NEO-47-perk-unlock-engine-perkstate-persistence` |
|
||||||
|
| **Blocked by** | [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) (catalog load — **Done** on `main`), [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (skill level source — **Done** on `main`) |
|
||||||
|
|
||||||
|
## Kickoff clarifications
|
||||||
|
|
||||||
|
| Topic | Question | Answer |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| **Tier 2 branch behavior** | At level 4, does the player pick a branch again or do tier-2 perks unlock automatically from the tier-1 choice? | User did not understand the question at kickoff → **Agent recommendation (adopted for this story):** **auto-path** — tier 1 is the only explicit branch pick; when skill level reaches tier 2’s `requiredLevel` (4 for salvage), perks on the **same `branchId`** as the tier-1 pick unlock with **no second pick**. Tier 2 branch rows stay in the catalog for metadata/perks; `TrySelectBranch` denies with `tier_branch_not_selectable` if all branches in that tier carry perks (see rule below). |
|
||||||
|
| **Postgres schema** | Normalized tables vs JSON blob vs defer Postgres? | User asked for recommendation → **Agent recommendation (adopted):** **normalized** tables + Flyway **`V004`**, mirroring NEO-38 (`player_mastery_branch_pick`, `player_unlocked_perk`). Linear AC requires in-memory + Postgres in this story. |
|
||||||
|
| **PerkUnlockEvent surfacing** | Return list vs DI sink vs both? | User asked for recommendation → **Agent recommendation (adopted):** **return `IReadOnlyList<PerkUnlockEvent>`** from engine methods; callers (grant hook, future HTTP in NEO-48) may ignore for now. NEO-49 can add an optional `IPerkUnlockEventSink` without changing persistence. |
|
||||||
|
| **Level-up hook site** | Inside `SkillProgressionGrantOperations` vs separate service? | **User:** run **inside `SkillProgressionGrantOperations`** when `levelUps` is non-empty (same path as XP write). Implementation: call a small **`PerkUnlockEngine`** instance method from there (logic stays testable in `Game/Mastery/`, not duplicated in HTTP). |
|
||||||
|
|
||||||
|
### Tier 1 vs tier 2 (plain language)
|
||||||
|
|
||||||
|
Prototype **salvage** mastery ([`prototype_salvage_mastery.json`](../../content/mastery/prototype_salvage_mastery.json)):
|
||||||
|
|
||||||
|
| Tier | Skill level gate | What happens |
|
||||||
|
|------|------------------|--------------|
|
||||||
|
| **1** | 2 | Player **chooses** `scrap_efficiency` **or** `bulk_haul`. **No perks** yet — only records the branch. |
|
||||||
|
| **2** | 4 | **One perk** unlocks on the path they already chose (e.g. `salvage_scrap_efficiency_1` if they picked `scrap_efficiency` at tier 1). **No second choice** between the two branches. |
|
||||||
|
|
||||||
|
So “mutually exclusive per tier” applies to **tier 1’s pick**; tier 2 is “follow your path,” not a new fork.
|
||||||
|
|
||||||
|
**Catalog rule (engine):** A tier **requires an explicit branch pick** when **every** branch in that tier has an empty `perkIds` array. A tier **auto-unlocks from path** when branches have perks and the player already has a `branchId` pick on the highest prior tier that required a pick (for prototype: tier 1 → tier 2).
|
||||||
|
|
||||||
|
## Goal, scope, and out-of-scope
|
||||||
|
|
||||||
|
**Goal:** Server-authoritative **`PerkState`** (unlocked `perkId` set + `branchId` per `tierIndex` per track) with unlock evaluation driven by **skill level** from E2.M2. When level crosses a tier gate, eligible tiers become selectable or auto-unlock per rules above. **Re-evaluate** on skill level-up after XP grants. Emit internal **`PerkUnlockEvent`** when perks unlock.
|
||||||
|
|
||||||
|
**In scope (from Linear):**
|
||||||
|
|
||||||
|
- `IPlayerPerkStateStore` — in-memory + Postgres + migration (NEO-38 pattern).
|
||||||
|
- Unlock engine: catalog + skill level; `PerkUnlockEvent` on unlock.
|
||||||
|
- Mutually exclusive branch pick where applicable; stable **`reasonCode`** on invalid picks.
|
||||||
|
- Level-up path after `SkillProgressionGrantOperations` when `levelUps` non-empty.
|
||||||
|
- Unit/integration tests (AAA): eligibility, branch select, deny paths, level-up auto-unlock.
|
||||||
|
|
||||||
|
**Out of scope (from Linear):**
|
||||||
|
|
||||||
|
- HTTP read/write (**NEO-48**).
|
||||||
|
- Gameplay `effectKind` application; respec; gig perks.
|
||||||
|
|
||||||
|
## Acceptance criteria checklist
|
||||||
|
|
||||||
|
- [x] `IPlayerPerkStateStore` with in-memory + Postgres path (migration) mirroring skill progression pattern.
|
||||||
|
- [x] Unlock engine reads catalog + skill level; emits internal `PerkUnlockEvent` when perks unlock.
|
||||||
|
- [x] Branch pick per tier is **mutually exclusive** where a tier requires a pick; stable deny `reasonCode` on invalid picks.
|
||||||
|
- [x] Level-up path (after `SkillProgressionGrantOperations`) triggers re-evaluation for affected skill.
|
||||||
|
- [x] Unit/integration tests (AAA): eligibility, branch select, deny paths.
|
||||||
|
|
||||||
|
## Technical approach
|
||||||
|
|
||||||
|
1. **Domain model (`Game/Mastery/`):**
|
||||||
|
- **`PerkStateSnapshot`** — `branchPicks`: `skillId` → `tierIndex` → `branchId`; `unlockedPerkIds`: set of `perkId`.
|
||||||
|
- **`PerkUnlockEvent`** — `playerId`, `perkId`, `skillId`, `tierIndex`, `branchId?`, `source` (`branch_pick` | `level_up`).
|
||||||
|
- **`PerkUnlockEngine`** — pure orchestration: `TrySelectBranch(...)`, `ReevaluateAfterLevelUp(...)`, both return **`PerkBranchSelectOutcome`** / **`PerkReevaluationOutcome`** with `success`, `reasonCode?`, `events`, updated snapshot.
|
||||||
|
|
||||||
|
2. **Eligibility (skill level):** Resolve level via `IPlayerSkillProgressionStore.GetXpTotals` + `ISkillLevelCurve.LevelFromTotalXp` (same as GET skill progression). Tier *T* is **reachable** when `level >= tier.RequiredLevel`.
|
||||||
|
|
||||||
|
3. **`TrySelectBranch(playerId, skillId, tierIndex, branchId)`:**
|
||||||
|
- Deny: `unknown_track`, `unknown_tier`, `unknown_branch`, `level_too_low`, `branch_already_chosen`, `tier_branch_not_selectable` (tier auto-unlocks from path — all branches have perks).
|
||||||
|
- Accept: persist pick; unlock any `perkIds` on the chosen branch for that tier (tier 1: none); emit events.
|
||||||
|
- Player write gate: store returns false when player missing from position/progression pattern → `player_not_found` (align with skill XP store).
|
||||||
|
|
||||||
|
4. **`ReevaluateAfterLevelUp(playerId, skillId, newLevel)`:**
|
||||||
|
- For each tier on track where `requiredLevel <= newLevel` and tier is **path-auto** (has perks, prior pick exists): unlock perks for branch matching prior pick’s `branchId`; skip already-unlocked ids; persist + events.
|
||||||
|
- Idempotent: re-running does not duplicate perks or events in state.
|
||||||
|
|
||||||
|
5. **Persistence (`IPlayerPerkStateStore`):**
|
||||||
|
- `GetSnapshot(string playerId)`
|
||||||
|
- `bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)` — fails if pick exists or player missing.
|
||||||
|
- `bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)` — additive, idempotent per perk.
|
||||||
|
- **Postgres:** `V004__player_perk_state.sql` — `player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id)` PK `(player_id, skill_id, tier_index)`; `player_unlocked_perk (player_id, perk_id)` PK `(player_id, perk_id)`; FK to `player_position`. Bootstrap: `PostgresPerkStateBootstrap` (mirror `PostgresSkillProgressionBootstrap`).
|
||||||
|
|
||||||
|
6. **DI:** `AddPerkStateStore(IServiceCollection, IConfiguration)` — Postgres when `ConnectionStrings:NeonSprawl` set, else in-memory. Register **`PerkUnlockEngine`** as singleton (depends on store, `IMasteryCatalogRegistry`, `IPlayerSkillProgressionStore`, `ISkillLevelCurve`).
|
||||||
|
|
||||||
|
7. **Level-up hook:** In **`SkillProgressionGrantOperations.TryApplyGrant`**, after successful XP apply, foreach entry in `levelUps` call `perkUnlockEngine.ReevaluateAfterLevelUp(playerId, levelUp.SkillId, levelUp.NewLevel)`. Inject `PerkUnlockEngine` via new overload or optional parameter on `TryApplyGrant` (prefer overload used by HTTP + interact to avoid static service locator). Events returned but not yet exposed on HTTP response (NEO-48).
|
||||||
|
|
||||||
|
8. **Reason codes (stable, `snake_case`):** Document in code constants on `PerkUnlockReasonCodes` (or engine type); mirror in tests.
|
||||||
|
|
||||||
|
| Code | When |
|
||||||
|
|------|------|
|
||||||
|
| `unknown_track` | No mastery track for `skillId` |
|
||||||
|
| `unknown_tier` | `tierIndex` not on track |
|
||||||
|
| `unknown_branch` | `branchId` not in tier |
|
||||||
|
| `level_too_low` | Skill level below `requiredLevel` |
|
||||||
|
| `branch_already_chosen` | Pick already stored for `(skillId, tierIndex)` |
|
||||||
|
| `tier_branch_not_selectable` | Tier auto-unlocks from path (no manual pick) |
|
||||||
|
| `player_not_found` | Store cannot write player |
|
||||||
|
|
||||||
|
9. **Tests:** Use repo `content/mastery` + level curve (`level 2` @ 100 XP, `level 4` @ 450 XP). Manipulate XP via `IPlayerSkillProgressionStore` or `TryApplyGrant` in integration tests.
|
||||||
|
|
||||||
|
## Files to add
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `server/db/migrations/V004__player_perk_state.sql` | Flyway DDL for branch picks + unlocked perks. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs` | In-memory read model for one player. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs` | Internal unlock event DTO. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs` | Stable deny reason constants. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs` | Result of `TrySelectBranch`. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs` | Result of `ReevaluateAfterLevelUp`. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs` | Persistence abstraction. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs` | In-memory store (dev player seed pattern from skill store). |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs` | Postgres implementation. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs` | One-time DDL apply for V004. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs` | `AddPerkStateStore` + register `PerkUnlockEngine`. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs` | Catalog + level + store orchestration. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs` | Unit tests: tier-1 pick, denies, level-4 auto-unlock, idempotent reevaluate. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs` | Postgres: branch pick + unlock survive new factory. |
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
| Path | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs` | Call `PerkUnlockEngine.ReevaluateAfterLevelUp` for each `levelUps` entry on successful grant (user kickoff). |
|
||||||
|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Pass `PerkUnlockEngine` into `TryApplyGrant` from POST handler. |
|
||||||
|
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Pass engine into gather XP grant path (NEO-41). |
|
||||||
|
| `server/NeonSprawl.Server/Program.cs` | `AddPerkStateStore(builder.Configuration)`. |
|
||||||
|
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Replace `IPlayerPerkStateStore` with in-memory implementation for isolated tests. |
|
||||||
|
| `server/NeonSprawl.Server/NeonSprawl.Server.csproj` | Copy `V004` SQL to output (if not already covered by existing migration copy pattern for V003). |
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Test file | Coverage |
|
||||||
|
|-----------|----------|
|
||||||
|
| `PerkUnlockEngineTests.cs` | **AAA:** At salvage level 2, `TrySelectBranch` tier 1 `scrap_efficiency` succeeds; other branch’s tier-2 perk not unlocked. **Deny:** `level_too_low` at level 1; `branch_already_chosen` on second pick; `unknown_branch`; `tier_branch_not_selectable` on tier 2 pick. **Level-up:** set XP to 450+, call `ReevaluateAfterLevelUp` → `salvage_scrap_efficiency_1` unlocked; `bulk_haul` perk not unlocked. **Idempotent:** second reevaluate adds no duplicate perks. |
|
||||||
|
| `PerkStatePersistenceIntegrationTests.cs` | **RequirePostgres:** pick branch + unlock via engine, new `PostgresWebApplicationFactory`, `GetSnapshot` shows pick + perk. |
|
||||||
|
| `SkillProgressionGrantOperations` (via existing grant API tests or one new test) | Grant salvage XP crossing level 4 with tier-1 pick already stored → perk unlocked without HTTP perk endpoint (hook integration smoke). |
|
||||||
|
|
||||||
|
All new `[Fact]` methods use `// Arrange` / `// Act` / `// Assert` per [csharp-style](../../.cursor/rules/csharp-style.md).
|
||||||
|
|
||||||
|
## Open questions / risks
|
||||||
|
|
||||||
|
- **Tier rule generalization:** “Empty `perkIds` on all branches ⇒ pick tier” must stay aligned with future multi-tier catalogs; add loader comment cross-ref to engine.
|
||||||
|
- **Grant hook + Postgres transactions:** Skill XP write and perk unlock are separate stores today (same as hotbar vs position); acceptable for prototype; document if cross-store atomicity becomes a requirement.
|
||||||
|
- **NEO-48 DTO shape:** Internal `PerkStateSnapshot` should map cleanly to versioned HTTP JSON without renaming fields.
|
||||||
|
|
||||||
|
None blocking after kickoff clarifications.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Tier 2 = auto-path from tier-1 `branchId` | E2M3 backlog “chosen branch path”; user needed plain-language explanation — adopted agent recommendation. |
|
||||||
|
| Normalized Postgres tables (V004) | User asked for recommendation; matches NEO-38 and query-friendly perk checks for future gates. |
|
||||||
|
| Return event list from engine | User asked for recommendation; minimal surface for NEO-47; NEO-49 can add sink. |
|
||||||
|
| Hook inside `SkillProgressionGrantOperations` | Explicit user choice; delegate to `PerkUnlockEngine` for testability. |
|
||||||
|
| Code under `Game/Mastery/` | NEO-46 boundary; cohesive with catalog registry. |
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Code review — NEO-47 perk unlock engine + PerkState persistence
|
||||||
|
|
||||||
|
**Date:** 2026-05-17
|
||||||
|
**Scope:** Branch `NEO-47-perk-unlock-engine-perkstate-persistence` · commits `e297b3a`–`0a2a6fe` vs `main`
|
||||||
|
**Base:** `main`
|
||||||
|
|
||||||
|
**Follow-up:** Addressed review suggestions in `0a2a6fe` (retroactive path-auto on branch pick, deny-code tests, unlock rollback, decomposition docs, gather-hook smoke, nits).
|
||||||
|
|
||||||
|
**Re-review (2026-05-17):** Verified follow-up commit against prior suggestions; no blocking regressions; **177** server tests pass.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Server-authoritative perk state (`IPlayerPerkStateStore` in-memory + Postgres `V004`), `PerkUnlockEngine` (tier-1 picks, path-auto tier-2, retroactive path-auto after pick at current level), and level-up re-evaluation in `SkillProgressionGrantOperations` (HTTP POST, gather interact, mission/refine helpers). Follow-up commit adds `ApplyPathAutoTiersAtOrBelowLevel`, branch-pick rollback on perk write failure, `CanWritePlayer` / `TryRemoveBranchPick`, expanded deny tests, gather-hook smoke, and E2.M3 doc alignment. All **177** server tests pass locally. Ready for merge from a code perspective; perk HTTP remains **NEO-48**.
|
||||||
|
|
||||||
|
## Documentation checked
|
||||||
|
|
||||||
|
| 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) | **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. |
|
||||||
|
|
||||||
|
Register/tracking: E2.M3 module doc and alignment row updated in follow-up (`0a2a6fe`).
|
||||||
|
|
||||||
|
## Blocking issues
|
||||||
|
|
||||||
|
None (initial review and re-review).
|
||||||
|
|
||||||
|
## Re-review (2026-05-17)
|
||||||
|
|
||||||
|
| Prior item | Status |
|
||||||
|
|------------|--------|
|
||||||
|
| Retroactive path-auto after branch pick | **Verified** — `ApplyPathAutoTiersAtOrBelowLevel` called from `TrySelectBranch`; test `TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant`. |
|
||||||
|
| Deny-code tests (`unknown_track`, `unknown_tier`, `player_not_found`) | **Verified** — three new `[Fact]` methods in `PerkUnlockEngineTests.cs`. |
|
||||||
|
| Unlock rollback on store failure | **Verified** — `TryRemoveBranchPick` + deny `player_not_found` when `TryUnlockPerks` fails after pick. |
|
||||||
|
| Decomposition docs | **Verified** — `E2_M3_MasteryAndPerkUnlocks.md` NEO-47 handoff; alignment table row updated. |
|
||||||
|
| Gather-hook smoke | **Verified** — `PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook` (440 + 10 XP gather). |
|
||||||
|
| Test scope / nits | **Verified** — singleton resolve from `factory.Services`; loader cross-ref on `TryGetTierIndexGateError`. |
|
||||||
|
|
||||||
|
**New issues from follow-up:** None blocking. Optional nit retained below (`unknown_track` for empty ids). Minor note: path-auto unlock events emitted from `TrySelectBranch` use `PerkUnlockSource.LevelUp` (same as reevaluate path) — acceptable for NEO-49 to refine if telemetry needs a distinct source for retroactive unlocks.
|
||||||
|
|
||||||
|
## 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.~~ **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.~~ **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`.~~ **Done.** Cross-ref on `TryGetTierIndexGateError` and `PerkUnlockEngine` class summary.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkUnlock|FullyQualifiedName~PerkState"
|
||||||
|
|
||||||
|
# Full server suite (177 tests at re-review)
|
||||||
|
dotnet test NeonSprawl.sln
|
||||||
|
|
||||||
|
# Postgres integration (when ConnectionStrings__NeonSprawl is set)
|
||||||
|
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkStatePersistenceIntegration"
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual: with Postgres, confirm `V004` tables exist after first perk write; grant salvage XP 100 → select tier-1 branch (future NEO-48) → grant to 450+ and confirm internal perk state via snapshot store or DB query until HTTP lands.
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -25,7 +26,11 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
@ -35,6 +40,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||||
d.ServiceType == typeof(TimeProvider) ||
|
d.ServiceType == typeof(TimeProvider) ||
|
||||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
||||||
|
|
@ -52,6 +58,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
|
||||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||||
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
|
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
using NeonSprawl.Server.Tests.Game.PositionState;
|
||||||
|
using Npgsql;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Mastery;
|
||||||
|
|
||||||
|
[Collection("Postgres integration")]
|
||||||
|
public sealed class PerkStatePersistenceIntegrationTests(PostgresIntegrationHarness harness)
|
||||||
|
{
|
||||||
|
private PostgresWebApplicationFactory Factory => harness.Factory;
|
||||||
|
|
||||||
|
[RequirePostgresFact]
|
||||||
|
public async Task BranchPickAndUnlock_ShouldPersistAcrossNewFactory()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await ResetPerkTablesAsync();
|
||||||
|
const string playerId = "dev-local-1";
|
||||||
|
using (var scope = Factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
|
var engine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
Assert.True(xpStore.TryApplyXpDelta(playerId, "salvage", 450, out _, out _));
|
||||||
|
Assert.True(engine.TrySelectBranch(playerId, "salvage", 1, "scrap_efficiency").Success);
|
||||||
|
_ = engine.ReevaluateAfterLevelUp(playerId, "salvage", newLevel: 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
PerkStateSnapshot snapshot;
|
||||||
|
await using (var secondFactory = new PostgresWebApplicationFactory())
|
||||||
|
{
|
||||||
|
using var scope = secondFactory.Services.CreateScope();
|
||||||
|
var perkStore = scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
|
||||||
|
snapshot = perkStore.GetSnapshot(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("scrap_efficiency", snapshot.BranchPicksBySkillId["salvage"][1]);
|
||||||
|
Assert.Contains("salvage_scrap_efficiency_1", snapshot.UnlockedPerkIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetPerkTablesAsync()
|
||||||
|
{
|
||||||
|
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
|
||||||
|
if (string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Factory.Services;
|
||||||
|
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
|
||||||
|
|
||||||
|
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
|
||||||
|
var perkDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V004__player_perk_state.sql");
|
||||||
|
if (!File.Exists(positionDdlPath) || !File.Exists(perkDdlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException("Test DDL for perk persistence not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
|
||||||
|
var perkDdl = await File.ReadAllTextAsync(perkDdlPath);
|
||||||
|
await using var conn = new NpgsqlConnection(cs);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
|
||||||
|
{
|
||||||
|
await applyPosition.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
|
||||||
|
{
|
||||||
|
await truncate.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
|
||||||
|
|
||||||
|
await using (var applyPerk = new NpgsqlCommand(perkDdl, conn))
|
||||||
|
{
|
||||||
|
await applyPerk.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
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";
|
||||||
|
private const string ScrapPerk = "salvage_scrap_efficiency_1";
|
||||||
|
private const string BulkPerk = "salvage_bulk_haul_1";
|
||||||
|
|
||||||
|
private static (PerkUnlockEngine Engine, IPlayerSkillProgressionStore XpStore, IPlayerPerkStateStore PerkStore) CreateEngine(
|
||||||
|
InMemoryWebApplicationFactory factory) =>
|
||||||
|
(
|
||||||
|
factory.Services.GetRequiredService<PerkUnlockEngine>(),
|
||||||
|
factory.Services.GetRequiredService<IPlayerSkillProgressionStore>(),
|
||||||
|
factory.Services.GetRequiredService<IPlayerPerkStateStore>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TrySelectBranch_AtSalvageLevel2_ShouldRecordPick_WithoutUnlockingTier2Perks()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
var (engine, xpStore, perkStore) = CreateEngine(factory);
|
||||||
|
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(outcome.Success);
|
||||||
|
Assert.Null(outcome.ReasonCode);
|
||||||
|
Assert.Empty(outcome.Events);
|
||||||
|
var snap = perkStore.GetSnapshot(DevPlayer);
|
||||||
|
Assert.Equal(ScrapBranch, snap.BranchPicksBySkillId[SalvageSkill][1]);
|
||||||
|
Assert.DoesNotContain(ScrapPerk, snap.UnlockedPerkIds);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
var (engine, _, _) = CreateEngine(factory);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
var (engine, xpStore, _) = CreateEngine(factory);
|
||||||
|
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
|
||||||
|
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, BulkBranch);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(PerkUnlockReasonCodes.BranchAlreadyChosen, outcome.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TrySelectBranch_WhenBranchUnknown_ShouldDenyWithUnknownBranch()
|
||||||
|
{
|
||||||
|
// 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: 1, "not_a_branch");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(PerkUnlockReasonCodes.UnknownBranch, outcome.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TrySelectBranch_OnPathAutoTier2_ShouldDenyWithTierBranchNotSelectable()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
var (engine, xpStore, _) = CreateEngine(factory);
|
||||||
|
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
|
||||||
|
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 2, ScrapBranch);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(outcome.Success);
|
||||||
|
Assert.Equal(PerkUnlockReasonCodes.TierBranchNotSelectable, outcome.ReasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReevaluateAfterLevelUp_AtLevel4WithTier1Pick_ShouldUnlockScrapPerkOnly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
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
|
||||||
|
Assert.Empty(outcome.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReevaluateAfterLevelUp_WhenCalledTwice_ShouldBeIdempotent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
_ = factory.CreateClient();
|
||||||
|
var (engine, xpStore, perkStore) = CreateEngine(factory);
|
||||||
|
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
|
||||||
|
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
|
||||||
|
_ = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var second = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(second.Events);
|
||||||
|
var snap = perkStore.GetSnapshot(DevPlayer);
|
||||||
|
Assert.Single(snap.UnlockedPerkIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostSkillProgressionGrant_AfterTier1PickAndLevel4Xp_ShouldUnlockPerkViaHook()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
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
|
||||||
|
{
|
||||||
|
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
|
||||||
|
SkillId = SalvageSkill,
|
||||||
|
Amount = 350,
|
||||||
|
SourceKind = "activity",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await client.PostAsJsonAsync($"/game/players/{DevPlayer}/skill-progression", grant);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(response.IsSuccessStatusCode);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -25,7 +26,11 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
||||||
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
@ -35,6 +40,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
||||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||||
d.ServiceType == typeof(TimeProvider) ||
|
d.ServiceType == typeof(TimeProvider) ||
|
||||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
||||||
|
|
@ -52,6 +58,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
|
||||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||||
services.AddSingleton<ISkillDefinitionRegistry, MissionRewardDeniedSkillRegistry>();
|
services.AddSingleton<ISkillDefinitionRegistry, MissionRewardDeniedSkillRegistry>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -23,6 +24,7 @@ public sealed class MissionRewardSkillXpGrantTests
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
MissionRewardSkillXpGrant.GrantFromMissionReward(
|
MissionRewardSkillXpGrant.GrantFromMissionReward(
|
||||||
|
|
@ -31,7 +33,8 @@ public sealed class MissionRewardSkillXpGrantTests
|
||||||
TestMissionRewardXp,
|
TestMissionRewardXp,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkEngine);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var totals = xpStore.GetXpTotals("dev-local-1");
|
var totals = xpStore.GetXpTotals("dev-local-1");
|
||||||
|
|
@ -55,6 +58,7 @@ public sealed class MissionRewardSkillXpGrantTests
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
MissionRewardSkillXpGrant.GrantFromMissionReward(
|
MissionRewardSkillXpGrant.GrantFromMissionReward(
|
||||||
|
|
@ -63,7 +67,8 @@ public sealed class MissionRewardSkillXpGrantTests
|
||||||
TestMissionRewardXp,
|
TestMissionRewardXp,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkEngine);
|
||||||
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -84,6 +89,7 @@ public sealed class MissionRewardSkillXpGrantTests
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
MissionRewardSkillXpGrant.GrantFromMissionReward(
|
MissionRewardSkillXpGrant.GrantFromMissionReward(
|
||||||
|
|
@ -92,7 +98,8 @@ public sealed class MissionRewardSkillXpGrantTests
|
||||||
TestMissionRewardXp,
|
TestMissionRewardXp,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkEngine);
|
||||||
|
|
||||||
// Assert — deny returns before TryApplyXpDelta; salvage row must not appear in stored XP map.
|
// Assert — deny returns before TryApplyXpDelta; salvage row must not appear in stored XP map.
|
||||||
var totals = xpStore.GetXpTotals("dev-local-1");
|
var totals = xpStore.GetXpTotals("dev-local-1");
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
@ -25,7 +26,11 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
||||||
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
|
|
@ -35,6 +40,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
||||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||||
d.ServiceType == typeof(TimeProvider) ||
|
d.ServiceType == typeof(TimeProvider) ||
|
||||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
|
||||||
|
|
@ -52,6 +58,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
|
||||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||||
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
|
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -21,9 +22,15 @@ public sealed class RefineActivitySkillXpGrantTests
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
|
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
|
||||||
|
"dev-local-1",
|
||||||
|
registry,
|
||||||
|
xpStore,
|
||||||
|
levelCurve,
|
||||||
|
perkEngine);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var totals = xpStore.GetXpTotals("dev-local-1");
|
var totals = xpStore.GetXpTotals("dev-local-1");
|
||||||
|
|
@ -47,9 +54,15 @@ public sealed class RefineActivitySkillXpGrantTests
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
|
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
|
||||||
|
"dev-local-1",
|
||||||
|
registry,
|
||||||
|
xpStore,
|
||||||
|
levelCurve,
|
||||||
|
perkEngine);
|
||||||
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|
@ -70,9 +83,15 @@ public sealed class RefineActivitySkillXpGrantTests
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
|
||||||
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
|
||||||
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
|
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
|
||||||
|
"dev-local-1",
|
||||||
|
registry,
|
||||||
|
xpStore,
|
||||||
|
levelCurve,
|
||||||
|
perkEngine);
|
||||||
|
|
||||||
// Assert — deny returns before TryApplyXpDelta; refine row must not appear in stored XP map.
|
// Assert — deny returns before TryApplyXpDelta; refine row must not appear in stored XP map.
|
||||||
var totals = xpStore.GetXpTotals("dev-local-1");
|
var totals = xpStore.GetXpTotals("dev-local-1");
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
if (d.ServiceType == typeof(IPositionStateStore) ||
|
if (d.ServiceType == typeof(IPositionStateStore) ||
|
||||||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
|
||||||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
|
||||||
|
d.ServiceType == typeof(IPlayerPerkStateStore) ||
|
||||||
d.ServiceType == typeof(TimeProvider) ||
|
d.ServiceType == typeof(TimeProvider) ||
|
||||||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
|
||||||
d.ServiceType == typeof(NpgsqlDataSource) ||
|
d.ServiceType == typeof(NpgsqlDataSource) ||
|
||||||
|
|
@ -58,6 +59,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
|
||||||
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
|
||||||
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
|
||||||
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
using NeonSprawl.Server.Game.World;
|
using NeonSprawl.Server.Game.World;
|
||||||
|
|
@ -15,7 +16,8 @@ public static class InteractionApi
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/players/{id}/interact",
|
"/game/players/{id}/interact",
|
||||||
(string id, InteractionRequest? body, IPositionStateStore store,
|
(string id, InteractionRequest? body, IPositionStateStore store,
|
||||||
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
|
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
|
||||||
{
|
{
|
||||||
|
|
@ -79,7 +81,8 @@ public static class InteractionApi
|
||||||
GatherSkillXpConstants.ActivitySourceKind,
|
GatherSkillXpConstants.ActivitySourceKind,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkUnlockEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Persisted mastery branch picks and unlocked perks per player (NEO-47).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Thread-safe in-memory perk state; seeds the configured dev player (NEO-47).</summary>
|
||||||
|
public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> options) : IPlayerPerkStateStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, PlayerPerkState> byPlayer = CreateInitialMap(options.Value);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static ConcurrentDictionary<string, PlayerPerkState> CreateInitialMap(GamePositionOptions o)
|
||||||
|
{
|
||||||
|
var id = NormalizePlayerId(o.DevPlayerId);
|
||||||
|
var map = new ConcurrentDictionary<string, PlayerPerkState>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
map[id] = new PlayerPerkState();
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var key = NormalizePlayerId(playerId);
|
||||||
|
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||||
|
{
|
||||||
|
return PerkStateSnapshot.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
return inner.ToSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)
|
||||||
|
{
|
||||||
|
var key = NormalizePlayerId(playerId);
|
||||||
|
var sid = skillId.Trim();
|
||||||
|
var bid = branchId.Trim();
|
||||||
|
if (key.Length == 0 || sid.Length == 0 || bid.Length == 0 || tierIndex < 1
|
||||||
|
|| !byPlayer.TryGetValue(key, out var inner))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
if (inner.HasBranchPick(sid, tierIndex))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.SetBranchPick(sid, tierIndex, bid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)
|
||||||
|
{
|
||||||
|
var key = NormalizePlayerId(playerId);
|
||||||
|
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (playerLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
foreach (var raw in perkIds)
|
||||||
|
{
|
||||||
|
var pid = raw.Trim();
|
||||||
|
if (pid.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.AddUnlockedPerk(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
if (string.IsNullOrEmpty(t))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PlayerPerkState
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Dictionary<int, string>> branchPicks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly HashSet<string> unlockedPerks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public bool HasBranchPick(string skillId, int tierIndex) =>
|
||||||
|
branchPicks.TryGetValue(skillId, out var tiers) && tiers.ContainsKey(tierIndex);
|
||||||
|
|
||||||
|
public void SetBranchPick(string skillId, int tierIndex, string branchId)
|
||||||
|
{
|
||||||
|
if (!branchPicks.TryGetValue(skillId, out var tiers))
|
||||||
|
{
|
||||||
|
tiers = new Dictionary<int, string>();
|
||||||
|
branchPicks[skillId] = tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
tiers[tierIndex] = branchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
foreach (var (skillId, tiers) in branchPicks)
|
||||||
|
{
|
||||||
|
bySkill[skillId] = new Dictionary<int, string>(tiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PerkStateSnapshot(bySkill, new HashSet<string>(unlockedPerks, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -289,7 +289,10 @@ public static class MasteryCatalogLoader
|
||||||
return new MasteryCatalog(masteryDirectory, tracksBySkillId, perksById, jsonFiles.Length);
|
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)
|
internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList<int> tierIndexValues)
|
||||||
{
|
{
|
||||||
if (tierIndexValues.Count == 0)
|
if (tierIndexValues.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="PerkUnlockEngine.TrySelectBranch"/> (NEO-47).</summary>
|
||||||
|
public sealed record PerkBranchSelectOutcome(
|
||||||
|
bool Success,
|
||||||
|
string? ReasonCode,
|
||||||
|
IReadOnlyList<PerkUnlockEvent> Events,
|
||||||
|
PerkStateSnapshot Snapshot);
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="PerkUnlockEngine.ReevaluateAfterLevelUp"/> (NEO-47).</summary>
|
||||||
|
public sealed record PerkReevaluationOutcome(
|
||||||
|
IReadOnlyList<PerkUnlockEvent> Events,
|
||||||
|
PerkStateSnapshot Snapshot);
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Registers perk state persistence and unlock engine (NEO-47).</summary>
|
||||||
|
public static class PerkStateServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddPerkStateStore(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(cs))
|
||||||
|
{
|
||||||
|
services.AddSingleton<IPlayerPerkStateStore, PostgresPlayerPerkStateStore>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton<PerkUnlockEngine>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Read model for one player's perk state (NEO-47).</summary>
|
||||||
|
public sealed class PerkStateSnapshot
|
||||||
|
{
|
||||||
|
public static PerkStateSnapshot Empty { get; } = new(
|
||||||
|
new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal),
|
||||||
|
new HashSet<string>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
public PerkStateSnapshot(
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> branchPicksBySkillId,
|
||||||
|
IReadOnlySet<string> unlockedPerkIds)
|
||||||
|
{
|
||||||
|
BranchPicksBySkillId = branchPicksBySkillId;
|
||||||
|
UnlockedPerkIds = unlockedPerkIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>skillId</c> → <c>tierIndex</c> → <c>branchId</c>.</summary>
|
||||||
|
public IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> BranchPicksBySkillId { get; }
|
||||||
|
|
||||||
|
public IReadOnlySet<string> UnlockedPerkIds { get; }
|
||||||
|
|
||||||
|
public bool TryGetBranchPick(string skillId, int tierIndex, out string branchId)
|
||||||
|
{
|
||||||
|
branchId = string.Empty;
|
||||||
|
if (!BranchPicksBySkillId.TryGetValue(skillId, out var tiers))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tiers.TryGetValue(tierIndex, out branchId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
|
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,
|
||||||
|
IPlayerPerkStateStore perkStore,
|
||||||
|
IPlayerSkillProgressionStore xpStore,
|
||||||
|
ISkillLevelCurve levelCurve)
|
||||||
|
{
|
||||||
|
/// <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,
|
||||||
|
int tierIndex,
|
||||||
|
string branchIdRaw)
|
||||||
|
{
|
||||||
|
var snapshot = perkStore.GetSnapshot(playerId);
|
||||||
|
var skillId = skillIdRaw.Trim();
|
||||||
|
var branchId = branchIdRaw.Trim();
|
||||||
|
if (skillId.Length == 0 || branchId.Length == 0)
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!catalog.TryGetTrack(skillId, out var track))
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tier = FindTier(track, tierIndex);
|
||||||
|
if (tier is null)
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TierIsPathAuto(tier))
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.TierBranchNotSelectable);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TierContainsBranch(tier, branchId))
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.UnknownBranch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!perkStore.CanWritePlayer(playerId))
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.PlayerNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
var level = GetSkillLevel(playerId, track.SkillId);
|
||||||
|
if (level < tier.RequiredLevel)
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.LevelTooLow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.TryGetBranchPick(track.SkillId, tier.TierIndex, out _))
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.BranchAlreadyChosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!perkStore.TrySetBranchPick(playerId, track.SkillId, tier.TierIndex, branchId))
|
||||||
|
{
|
||||||
|
return Deny(snapshot, PerkUnlockReasonCodes.PlayerNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
var branch = tier.Branches.First(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
|
||||||
|
var allEvents = new List<PerkUnlockEvent>();
|
||||||
|
if (!TryUnlockPerks(
|
||||||
|
playerId,
|
||||||
|
track.SkillId,
|
||||||
|
tier.TierIndex,
|
||||||
|
branch.BranchId,
|
||||||
|
branch.PerkIds,
|
||||||
|
PerkUnlockSource.BranchPick,
|
||||||
|
snapshot,
|
||||||
|
out var pickEvents))
|
||||||
|
{
|
||||||
|
_ = perkStore.TryRemoveBranchPick(playerId, track.SkillId, tier.TierIndex);
|
||||||
|
return Deny(perkStore.GetSnapshot(playerId), PerkUnlockReasonCodes.PlayerNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
new(false, reason, [], snapshot);
|
||||||
|
|
||||||
|
private int GetSkillLevel(string playerId, string skillId)
|
||||||
|
{
|
||||||
|
var xpBySkill = xpStore.GetXpTotals(playerId);
|
||||||
|
xpBySkill.TryGetValue(skillId, out var xp);
|
||||||
|
return levelCurve.LevelFromTotalXp(xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MasteryTierRow? FindTier(MasteryTrackRow track, int tierIndex) =>
|
||||||
|
track.Tiers.FirstOrDefault(t => t.TierIndex == tierIndex);
|
||||||
|
|
||||||
|
/// <summary>True when every branch has no perks — player must pick a branch explicitly.</summary>
|
||||||
|
internal static bool TierRequiresExplicitPick(MasteryTierRow tier) =>
|
||||||
|
tier.Branches.All(static b => b.PerkIds.Count == 0);
|
||||||
|
|
||||||
|
internal static bool TierIsPathAuto(MasteryTierRow tier) => !TierRequiresExplicitPick(tier);
|
||||||
|
|
||||||
|
private static bool TierContainsBranch(MasteryTierRow tier, string branchId) =>
|
||||||
|
tier.Branches.Any(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>Branch id from the highest prior tier that required an explicit pick.</summary>
|
||||||
|
internal static string? GetPathBranchId(PerkStateSnapshot snapshot, MasteryTrackRow track, MasteryTierRow tier)
|
||||||
|
{
|
||||||
|
MasteryTierRow? anchor = null;
|
||||||
|
foreach (var prior in track.Tiers.OrderByDescending(static t => t.TierIndex))
|
||||||
|
{
|
||||||
|
if (prior.TierIndex >= tier.TierIndex)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TierRequiresExplicitPick(prior))
|
||||||
|
{
|
||||||
|
anchor = prior;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.TryGetBranchPick(track.SkillId, anchor.TierIndex, out var bid) ? bid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
PerkStateSnapshot snapshot,
|
||||||
|
out IReadOnlyList<PerkUnlockEvent> events)
|
||||||
|
{
|
||||||
|
events = [];
|
||||||
|
if (perkIds.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newIds = new List<string>();
|
||||||
|
foreach (var perkId in perkIds)
|
||||||
|
{
|
||||||
|
if (!snapshot.UnlockedPerkIds.Contains(perkId))
|
||||||
|
{
|
||||||
|
newIds.Add(perkId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIds.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!perkStore.TryAddUnlockedPerks(playerId, newIds))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
events = newIds
|
||||||
|
.Select(pid => new PerkUnlockEvent(playerId, pid, skillId, tierIndex, branchId, source))
|
||||||
|
.ToList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Internal event when a perk becomes unlocked (NEO-47; NEO-49 telemetry).</summary>
|
||||||
|
public enum PerkUnlockSource
|
||||||
|
{
|
||||||
|
BranchPick,
|
||||||
|
LevelUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="BranchId">Chosen branch when relevant; null for edge cases without a branch context.</param>
|
||||||
|
public sealed record PerkUnlockEvent(
|
||||||
|
string PlayerId,
|
||||||
|
string PerkId,
|
||||||
|
string SkillId,
|
||||||
|
int TierIndex,
|
||||||
|
string? BranchId,
|
||||||
|
PerkUnlockSource Source);
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Stable deny codes for perk branch selection (NEO-47; NEO-48 HTTP).</summary>
|
||||||
|
public static class PerkUnlockReasonCodes
|
||||||
|
{
|
||||||
|
public const string UnknownTrack = "unknown_track";
|
||||||
|
public const string UnknownTier = "unknown_tier";
|
||||||
|
public const string UnknownBranch = "unknown_branch";
|
||||||
|
public const string LevelTooLow = "level_too_low";
|
||||||
|
public const string BranchAlreadyChosen = "branch_already_chosen";
|
||||||
|
public const string TierBranchNotSelectable = "tier_branch_not_selectable";
|
||||||
|
public const string PlayerNotFound = "player_not_found";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
|
/// <summary>Applies NEO-47 perk state table DDL once per process.</summary>
|
||||||
|
public static class PostgresPerkStateBootstrap
|
||||||
|
{
|
||||||
|
private static readonly string DdlRelativePath = Path.Combine("db", "migrations", "V004__player_perk_state.sql");
|
||||||
|
|
||||||
|
private static readonly object SchemaGate = new();
|
||||||
|
|
||||||
|
private static int _schemaReady;
|
||||||
|
|
||||||
|
public static void EnsureSchema(Npgsql.NpgsqlDataSource dataSource)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SchemaGate)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _schemaReady) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddlPath = Path.Combine(AppContext.BaseDirectory, DdlRelativePath);
|
||||||
|
if (!File.Exists(ddlPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"NEO-47 DDL not found at '{ddlPath}'.", ddlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ddl = File.ReadAllText(ddlPath);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(ddl, conn);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
Volatile.Write(ref _schemaReady, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var norm = NormalizePlayerId(playerId);
|
||||||
|
if (norm.Length == 0)
|
||||||
|
{
|
||||||
|
return PerkStateSnapshot.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
|
||||||
|
var branchPicks = new Dictionary<string, Dictionary<int, string>>(StringComparer.Ordinal);
|
||||||
|
using (var pickCmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT skill_id, tier_index, branch_id
|
||||||
|
FROM player_mastery_branch_pick
|
||||||
|
WHERE player_id = @pid;
|
||||||
|
""",
|
||||||
|
conn))
|
||||||
|
{
|
||||||
|
pickCmd.Parameters.AddWithValue("pid", norm);
|
||||||
|
using var reader = pickCmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
var skillId = reader.GetString(0);
|
||||||
|
var tierIndex = reader.GetInt32(1);
|
||||||
|
var branchId = reader.GetString(2);
|
||||||
|
if (!branchPicks.TryGetValue(skillId, out var tiers))
|
||||||
|
{
|
||||||
|
tiers = new Dictionary<int, string>();
|
||||||
|
branchPicks[skillId] = tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
tiers[tierIndex] = branchId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bySkill = new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);
|
||||||
|
foreach (var (skillId, tiers) in branchPicks)
|
||||||
|
{
|
||||||
|
bySkill[skillId] = tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unlocked = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
using (var perkCmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT perk_id
|
||||||
|
FROM player_unlocked_perk
|
||||||
|
WHERE player_id = @pid;
|
||||||
|
""",
|
||||||
|
conn))
|
||||||
|
{
|
||||||
|
perkCmd.Parameters.AddWithValue("pid", norm);
|
||||||
|
using var reader = perkCmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
unlocked.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PerkStateSnapshot(bySkill, unlocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)
|
||||||
|
{
|
||||||
|
var norm = NormalizePlayerId(playerId);
|
||||||
|
var sid = skillId.Trim();
|
||||||
|
var bid = branchId.Trim();
|
||||||
|
if (norm.Length == 0 || sid.Length == 0 || bid.Length == 0 || tierIndex < 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
if (!PlayerExists(conn, norm, tx))
|
||||||
|
{
|
||||||
|
tx.Rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var exists = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM player_mastery_branch_pick
|
||||||
|
WHERE player_id = @pid AND skill_id = @sid AND tier_index = @tier
|
||||||
|
LIMIT 1;
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx))
|
||||||
|
{
|
||||||
|
exists.Parameters.AddWithValue("pid", norm);
|
||||||
|
exists.Parameters.AddWithValue("sid", sid);
|
||||||
|
exists.Parameters.AddWithValue("tier", tierIndex);
|
||||||
|
if (exists.ExecuteScalar() is not null)
|
||||||
|
{
|
||||||
|
tx.Rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var insert = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id, updated_at)
|
||||||
|
VALUES (@pid, @sid, @tier, @bid, now());
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
insert.Parameters.AddWithValue("pid", norm);
|
||||||
|
insert.Parameters.AddWithValue("sid", sid);
|
||||||
|
insert.Parameters.AddWithValue("tier", tierIndex);
|
||||||
|
insert.Parameters.AddWithValue("bid", bid);
|
||||||
|
insert.ExecuteNonQuery();
|
||||||
|
tx.Commit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)
|
||||||
|
{
|
||||||
|
var norm = NormalizePlayerId(playerId);
|
||||||
|
if (norm.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toAdd = new List<string>();
|
||||||
|
foreach (var raw in perkIds)
|
||||||
|
{
|
||||||
|
var pid = raw.Trim();
|
||||||
|
if (pid.Length > 0)
|
||||||
|
{
|
||||||
|
toAdd.Add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPerkStateBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
using var tx = conn.BeginTransaction();
|
||||||
|
if (!PlayerExists(conn, norm, tx))
|
||||||
|
{
|
||||||
|
tx.Rollback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var perkId in toAdd)
|
||||||
|
{
|
||||||
|
using var upsert = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO player_unlocked_perk (player_id, perk_id, unlocked_at)
|
||||||
|
VALUES (@pid, @perk, now())
|
||||||
|
ON CONFLICT (player_id, perk_id) DO NOTHING;
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
upsert.Parameters.AddWithValue("pid", norm);
|
||||||
|
upsert.Parameters.AddWithValue("perk", perkId);
|
||||||
|
upsert.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit();
|
||||||
|
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(
|
||||||
|
"SELECT 1 FROM player_position WHERE player_id = @pid LIMIT 1;",
|
||||||
|
conn,
|
||||||
|
tx);
|
||||||
|
cmd.Parameters.AddWithValue("pid", playerIdNormalized);
|
||||||
|
return cmd.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePlayerId(string? playerId)
|
||||||
|
{
|
||||||
|
var t = playerId?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(t))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -15,7 +17,8 @@ public static class MissionRewardSkillXpGrant
|
||||||
int amount,
|
int amount,
|
||||||
ISkillDefinitionRegistry registry,
|
ISkillDefinitionRegistry registry,
|
||||||
IPlayerSkillProgressionStore xpStore,
|
IPlayerSkillProgressionStore xpStore,
|
||||||
ISkillLevelCurve levelCurve)
|
ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine)
|
||||||
{
|
{
|
||||||
_ = SkillProgressionGrantOperations.TryApplyGrant(
|
_ = SkillProgressionGrantOperations.TryApplyGrant(
|
||||||
playerId,
|
playerId,
|
||||||
|
|
@ -24,6 +27,7 @@ public static class MissionRewardSkillXpGrant
|
||||||
MissionRewardSkillXpConstants.MissionRewardSourceKind,
|
MissionRewardSkillXpConstants.MissionRewardSourceKind,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkUnlockEngine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -13,7 +15,8 @@ public static class RefineActivitySkillXpGrant
|
||||||
string playerId,
|
string playerId,
|
||||||
ISkillDefinitionRegistry registry,
|
ISkillDefinitionRegistry registry,
|
||||||
IPlayerSkillProgressionStore xpStore,
|
IPlayerSkillProgressionStore xpStore,
|
||||||
ISkillLevelCurve levelCurve)
|
ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine)
|
||||||
{
|
{
|
||||||
_ = SkillProgressionGrantOperations.TryApplyGrant(
|
_ = SkillProgressionGrantOperations.TryApplyGrant(
|
||||||
playerId,
|
playerId,
|
||||||
|
|
@ -22,6 +25,7 @@ public static class RefineActivitySkillXpGrant
|
||||||
RefineSkillXpConstants.ActivitySourceKind,
|
RefineSkillXpConstants.ActivitySourceKind,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkUnlockEngine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
||||||
/// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary>
|
/// <summary>Shared NEO-38 skill XP grant apply (used by HTTP POST and NEO-41 gather interact hook).</summary>
|
||||||
|
|
@ -14,7 +16,8 @@ public static class SkillProgressionGrantOperations
|
||||||
string sourceKindRaw,
|
string sourceKindRaw,
|
||||||
ISkillDefinitionRegistry registry,
|
ISkillDefinitionRegistry registry,
|
||||||
IPlayerSkillProgressionStore xpStore,
|
IPlayerSkillProgressionStore xpStore,
|
||||||
ISkillLevelCurve levelCurve)
|
ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine)
|
||||||
{
|
{
|
||||||
static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) =>
|
static SkillProgressionGrantResponse Deny(SkillProgressionSnapshotResponse snapshot, string reason) =>
|
||||||
new()
|
new()
|
||||||
|
|
@ -80,6 +83,14 @@ public static class SkillProgressionGrantOperations
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (levelUps.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var levelUp in levelUps)
|
||||||
|
{
|
||||||
|
_ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, levelUp.SkillId, levelUp.NewLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve);
|
var afterSnapshot = SkillProgressionSnapshotApi.BuildSnapshot(playerId, registry, xpStore, levelCurve);
|
||||||
return new SkillProgressionGrantApplyOutcome(
|
return new SkillProgressionGrantApplyOutcome(
|
||||||
SkillProgressionGrantApplyKind.Granted,
|
SkillProgressionGrantApplyKind.Granted,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Skills;
|
namespace NeonSprawl.Server.Game.Skills;
|
||||||
|
|
@ -31,7 +32,8 @@ public static class SkillProgressionSnapshotApi
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/players/{id}/skill-progression",
|
"/game/players/{id}/skill-progression",
|
||||||
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
|
(string id, SkillProgressionGrantRequest? body, IPositionStateStore positions,
|
||||||
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
|
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
|
||||||
|
PerkUnlockEngine perkUnlockEngine) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != SkillProgressionGrantRequest.CurrentSchemaVersion)
|
||||||
{
|
{
|
||||||
|
|
@ -51,7 +53,8 @@ public static class SkillProgressionSnapshotApi
|
||||||
body.SourceKind,
|
body.SourceKind,
|
||||||
registry,
|
registry,
|
||||||
xpStore,
|
xpStore,
|
||||||
levelCurve);
|
levelCurve,
|
||||||
|
perkUnlockEngine);
|
||||||
|
|
||||||
return outcome.Kind switch
|
return outcome.Kind switch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddPositionStateStore(builder.Configuration);
|
builder.Services.AddPositionStateStore(builder.Configuration);
|
||||||
builder.Services.AddHotbarLoadoutStore(builder.Configuration);
|
builder.Services.AddHotbarLoadoutStore(builder.Configuration);
|
||||||
builder.Services.AddSkillProgressionStore(builder.Configuration);
|
builder.Services.AddSkillProgressionStore(builder.Configuration);
|
||||||
|
builder.Services.AddPerkStateStore(builder.Configuration);
|
||||||
builder.Services.AddAbilityCooldownStore();
|
builder.Services.AddAbilityCooldownStore();
|
||||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||||
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- NEO-47: per-player mastery branch picks and unlocked perk ids.
|
||||||
|
CREATE TABLE IF NOT EXISTS player_mastery_branch_pick (
|
||||||
|
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
|
||||||
|
skill_id TEXT NOT NULL,
|
||||||
|
tier_index INTEGER NOT NULL CHECK (tier_index >= 1),
|
||||||
|
branch_id TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (player_id, skill_id, tier_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_unlocked_perk (
|
||||||
|
player_id TEXT NOT NULL REFERENCES player_position(player_id) ON DELETE CASCADE,
|
||||||
|
perk_id TEXT NOT NULL,
|
||||||
|
unlocked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (player_id, perk_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE player_mastery_branch_pick IS 'Mutually exclusive branch picks per mastery tier (NEO-47); tier 1 pick drives path-auto tier unlocks.';
|
||||||
|
COMMENT ON TABLE player_unlocked_perk IS 'Unlocked perk ids for a player (NEO-47); additive, idempotent per perk_id.';
|
||||||
Loading…
Reference in New Issue