**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**.
**Landed ([NEO-47](https://linear.app/neon-sprawl/issue/NEO-47)):** `IPlayerPerkStateStore` (in-memory + Postgres **`V004`**) persists branch picks and unlocked `perkId`s. **`PerkUnlockEngine`** evaluates tier-1 explicit branch picks and **path-auto** tier-2 unlocks from the tier-1 `branchId` when skill level meets gates (no second pick at tier 2 for prototype salvage). Level-up re-evaluation runs inside **`SkillProgressionGrantOperations`** after XP grants (HTTP POST, gather interact, mission/refine helpers). Stable deny **`reasonCode`** values for invalid picks. Internal **`PerkUnlockEvent`** returned from engine methods. **HTTP read/write** for perk state is **NEO-48**.
**Landed ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)):** Comment-only **`perk_unlock`** hook site in **`PerkUnlockEngine.TryUnlockPerks`** (authoritative unlock anchor; covers branch-pick and level-up path-auto). **`TODO(E9.M1)`**; no production ingest. [server README — Perk unlock engine and telemetry hooks](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49); plan [NEO-49](../../plans/NEO-49-implementation-plan.md); manual QA [`NEO-49.md`](../../manual-qa/NEO-49.md).
- **Risk:** Mastery feels mandatory for one skill and blocks alts — **Mitigation:** flagship is one skill; other prototype skills remain level-only until expanded.
- **Risk:** Branch picks without respec frustrate players — **Mitigation:** defer respec to late-stage tuning; keep v1 tree small.
- **Telemetry:**`perk_unlock` hook sites ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)); align with [E9.M1](E9_M1_TelemetryEventSchema.md) when catalog exists.
- **Telemetry:****`perk_unlock`** hook site in **`PerkUnlockEngine.TryUnlockPerks`** ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) — comments-only); E9.M1 catalog ingest deferred.
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`).
- This story adds **comments only**; perk unlock behavior matches NEO-47.
## Code review check
- [ ] Open `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs` and locate **`TryUnlockPerks`**. Confirm a **comment block** marks the future E9.M1 event **`perk_unlock`** after `PerkUnlockEvent` instances are built.
@ -50,6 +50,12 @@ After the skill catalog loads, the host loads every **`*_mastery.json`** under t
On success, **Information** logs include the resolved mastery directory, track count, and perk count. Game code should use **`IMasteryCatalogRegistry`** for lookups (NEO-47+).
## Perk unlock engine and telemetry hooks (NEO-47, NEO-49)
**`PerkUnlockEngine`** (`Game/Mastery/`) is the authoritative server path for branch picks and perk unlocks: **`TrySelectBranch`**, **`ReevaluateAfterLevelUp`** (called from **`SkillProgressionGrantOperations`** on skill level-up). Persistence: **`IPlayerPerkStateStore`** + Flyway **`V004`** ([NEO-47 plan](../../docs/plans/NEO-47-implementation-plan.md)).
**NEO-49:** comment-only **telemetry hook site** for future catalog event **`perk_unlock`** in **`PerkUnlockEngine.TryUnlockPerks`** when new perk ids are persisted (one emit per unlocked perk; **`TODO(E9.M1)`** — no production ingest). Manual QA: [`docs/manual-qa/NEO-49.md`](../../docs/manual-qa/NEO-49.md); plan: [NEO-49 implementation plan](../../docs/plans/NEO-49-implementation-plan.md).
## Skill definitions (NEO-36)
**`GET /game/world/skill-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`skills`**) backed by **`ISkillDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-36 implementation plan](../../docs/plans/NEO-36-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-36.md`](../../docs/manual-qa/NEO-36.md); Bruno: `bruno/neon-sprawl-server/skill-definitions/`.