# E2.M3 — MasteryAndPerkUnlocks

## Summary

| Field | Value |
|--------|--------|
| **Module ID** | E2.M3 |
| **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) |
| **Linear (Epic 2 project)** | [Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) |
| **Stage target** | Pre-production |
| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed; **NEO-46** server load; **NEO-47** perk engine + persistence landed) |

## Linear backlog (Epic 2 Slice 4 — E2.M3)

Issues use label **`E2.M3`**. Dependency order: **NEO-45 → NEO-46 → NEO-47 → NEO-48**; **NEO-49** after **NEO-47** (may parallel **NEO-48**). Canonical story text: [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md).

| Order | Issue | Summary |
|------:|-------|---------|
| 1 | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45/e2m3-lock-prototype-salvage-mastery-catalog-schemas-ci) | Lock prototype **salvage** mastery catalog + schemas + CI |
| 2 | [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast) | Server loads mastery catalog at startup (fail-fast) |
| 3 | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence) | Perk unlock engine + `PerkState` persistence |
| 4 | [NEO-48](https://linear.app/neon-sprawl/issue/NEO-48/e2m3-perk-state-get-branch-selection-post-bruno) | Perk state GET + branch selection POST + Bruno |
| 5 | [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49/e2m3-perk-unlock-telemetry-hook-sites) | `perk_unlock` telemetry hook sites |

`docs/plans/NEO-*-implementation-plan.md` files are added at story kickoff (per [planning-implementation-docs](../../../.cursor/rules/planning-implementation-docs.md)).

## Purpose

Mastery tracks and perk unlock state so **skill** builds have expressive identity beyond raw level curves from [E2.M2](E2_M2_XpAwardAndLevelEngine.md). **Gig** mastery/perks remain a **separate** system ([`docs/game-design/gigs.md`](../../game-design/gigs.md)).

## v1 design decisions (frozen for backlog)

| Topic | v1 choice |
|-------|-----------|
| Progress into mastery | **Skill level** from E2.M2 (`IPlayerSkillProgressionStore`) — no separate mastery XP bar |
| Branch model | One **`MasteryTrack`** per skill; **tiers** with **mutually exclusive** branch picks per tier |
| Respec | **Out of scope** (late-stage tuning per [`skills.md`](../../game-design/skills.md)) |
| Gig perks | **No** shared tree with gigs |
| Gameplay effects | **State + data only** in E2.M3 core; stat/yield hooks are optional follow-ups (e.g. E3.M1) |

## Data contract (schema)

| Artifact | Path |
|----------|------|
| **JSON Schema** | [`content/schemas/mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json) |
| **Prototype catalog** | [`content/mastery/prototype_salvage_mastery.json`](../../../content/mastery/prototype_salvage_mastery.json) |

Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId` → **`PerkDef`**), and a **`tracks`** array. Branches reference perks by id string only (no inline perk objects). CI requires each **`perks`** map key to equal the nested **`PerkDef.id`** (see [`mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json) and [`validate_content.py`](../../../scripts/validate_content.py)).

**`tracks[]` → `MasteryTrack` → `tiers[]`:** each tier row has **`tierIndex`** (1-based, stable for `PerkState`), **`requiredLevel`** (skill level from E2.M2), and **`branches[]`**. Each branch has **`branchId`**, optional **`displayName`**, and **`perkIds[]`** (may be empty at tier 1).

**`MasteryTrack` fields (prototype):**

| Field | Rule |
|--------|------|
| **`skillId`** | Must exist in [E2.M1](E2_M1_SkillDefinitionRegistry.md) `SkillDef` catalog (`salvage` for flagship slice). |
| **`tiers`** | Ordered list; each tier has **`requiredLevel`** (skill level from E2.M2) and **`branches`**. |
| **`branches`** | Stable **`branchId`**; **mutually exclusive** pick per tier; each branch lists **`perkIds`** unlocked when chosen. |

**`PerkDef` fields (prototype, embedded or referenced in catalog):**

| Field | Rule |
|--------|------|
| **`id`** | Lowercase `snake_case`, **immutable** after ship. |
| **`displayName`** | Player-facing; may change without migrating unlock state. |
| **`effectKind`** | Optional stub string for future systems (gather yield, craft efficiency, etc.) — **no** gameplay apply in E2.M3 core stories. |

### Designer note: tiers, branches, and level gates

- **Level gates** come from E2.M2 only: when a player’s **skill level** reaches **`requiredLevel`**, that tier’s branches become **eligible** for selection (server-authoritative).
- **One branch per tier:** picking `scrap_efficiency` forecloses `bulk_haul` for that tier; perks on the chosen branch unlock; the other branch’s perks for that tier do **not** unlock.
- **Other skills** (`refine`, `intrusion`) have **no** mastery rows in the prototype catalog until a deliberate expansion issue relaxes CI.
- **Respec** is not implemented in v1 — document in content if a perk is “permanent for the character.”
- **`displayName`** on a branch row is optional in schema; tier 1 and tier 2 may both include it for consistent authoring.

## NEO-46 handoff (server catalog load)

**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.

## NEO-48 handoff (perk state HTTP + Bruno)

**Landed ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)):** Versioned **`GET`/`POST /game/players/{id}/perk-state`** — read model + branch selection via **`PerkUnlockEngine`**; structured **`selected`** / **`reasonCode`** / **`unlockedEvents`** envelopes. Bruno: `bruno/neon-sprawl-server/perk-state/`; [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); plan [NEO-48](../../plans/NEO-48-implementation-plan.md); manual QA [`NEO-48.md`](../../manual-qa/NEO-48.md).

## NEO-49 handoff (perk_unlock telemetry hook sites)

**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).

## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))

The **first shipped mastery track** is **`salvage`** only:

| Tier | `requiredLevel` | Branches (pick one) | Tier-2 `perkId` (chosen branch only) |
|------|-----------------|---------------------|--------------------------------------|
| 1 | 2 | `scrap_efficiency` · `bulk_haul` | *(none — branch pick only)* |
| 2 | 4 | same branch ids | `salvage_scrap_efficiency_1` · `salvage_bulk_haul_1` |

**Rules:** Do **not** rename `branchId` / `perkId` values to “fix” wording—change `displayName` only, or add new ids and migrate content. Expanding mastery to **`refine`** / **`intrusion`** belongs in a new Linear issue once the catalog gate intentionally grows.

## Responsibilities

- Load and validate mastery catalog from data; expose track/perk metadata via `IMasteryCatalogRegistry` (name flexible).
- Persist **`PerkState`** per player: unlocked **`perkId`** set + chosen **`branchId`** per tier.
- Evaluate eligibility from **skill level**; apply branch selection; emit **`PerkUnlockEvent`** for telemetry and future hooks.
- Re-evaluate eligibility on **skill level-up** (after E2.M2 grants).
- Expose versioned HTTP read/write for perk state (prototype JSON v1).

## Key contracts

| Contract | Kind | Role |
|----------|------|------|
| `MasteryTrack` | Content | Per-skill tier/branch/perk tree. |
| `PerkDef` | Content | Stable perk metadata (+ optional `effectKind` stub). |
| `PerkState` | Server-internal + HTTP DTO | Unlocked perks + branch picks per tier. |
| `PerkUnlockEvent` | Server-internal | Fired when a perk becomes unlocked (UI/telemetry/craft gates later). |

## Module dependencies

- **E2.M2** — [XpAwardAndLevelEngine](E2_M2_XpAwardAndLevelEngine.md): skill **level** source for tier gates; level-up hook site for re-evaluation.
- **E2.M1** — [SkillDefinitionRegistry](E2_M1_SkillDefinitionRegistry.md): validate catalog `skillId` references.

## Dependents (by design)

- **E3.M1 / E3.M2** — Optional gates or modifiers on gather/craft when perk `effectKind` is wired (follow-up integration, not E2.M3 core).
- **E7.M2** — Mission rewards may reference perk prerequisites in content (future).
- Gameplay systems that gate recipes or abilities on **`perkId`** (integration per content).

## Acceptance criteria (E2.M3 / Slice 4)

Module-level bullets below span **NEO-45 → NEO-49**. **[NEO-45](https://linear.app/neon-sprawl/issue/NEO-45)** satisfies **data + CI** only (catalog, schema, `validate_content.py`); runtime unlock, HTTP, and telemetry are **NEO-46+**.

- At least one **salvage** mastery path unlocks a perk **without** blocking progression in **`refine`** or **`intrusion`** (separate skills / tracks).
- Branch pick at tier 1 is **mutually exclusive**; server denies invalid or duplicate picks with stable **`reasonCode`** values.
- Catalog + perk state validated in CI or at server boot ([CT.M1](CT_M1_ContentValidationPipeline.md)).
- Telemetry hook sites documented for **`perk_unlock`** (no E9.M1 ingest until catalog exists).

## Related implementation slices

- **Epic 2 Slice 4** — E2.M3 (this module) + [E2.M4](E2_M4_ProgressionPacingControls.md) pacing (separate backlog; depends on E9.M2).
- **Epic 2 Slice 3** — skill XP integration remains on E2.M2; **NEO-44** (gig XP) is Epic 5, not E2.M3.

## Risks and telemetry

- **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 site in **`PerkUnlockEngine.TryUnlockPerks`** ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) — comments-only); E9.M1 catalog ingest deferred.

## Source anchors

- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 2, E2.M3.
- Game design: [`docs/game-design/skills.md`](../../game-design/skills.md) — Mastery and perks.
- [Module dependency register](module_dependency_register.md)
