Merge pull request #79 from ViPro-Technologies/NEO-45-salvage-mastery-catalog-schemas-ci

NEO-45: Lock prototype salvage mastery catalog, schemas, and CI
pull/80/head
VinPropane 2026-05-17 18:00:06 -04:00 committed by GitHub
commit 4ede4798d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 963 additions and 29 deletions

View File

@ -1,15 +1,23 @@
---
description: Commits at agent discretion; push only when user asks; GitHub MCP create_pull_request with NEO-* title when opening PRs; PR text without tool boilerplate
description: Commit as you go on story work (no wait for "commit"); push only when user asks; GitHub MCP for PRs
alwaysApply: true
---
# Commits and review (Neon Sprawl)
## When the agent may commit
## Commit as you go (required on story work)
- You **may** run **`git commit`** at your **discretion** while **working on a Linear issue** (or other ticketed work): logical checkpoints, end of a coherent change, plan-only commits on the story branch, implementation batches, test additions, etc.
- Use judgment: **small, coherent commits** are easier to review than one huge dump; match [linear-git-naming](linear-git-naming.md) for message format when a ticket applies.
- If the user is **not** on a story branch and the change is **exploratory** or **ambiguous**, prefer leaving the working tree uncommitted and summarizing until scope is clear—unless they asked you to commit.
**This repo expects agents to commit.** Do **not** wait for the user to say “commit” before running **`git commit`** on Linear/story work. That overrides any global Cursor guidance to “only commit when asked.”
**Commit on the story branch** when work is coherent and reviewable, including:
- Kickoff **`docs/plans/{KEY}-implementation-plan.md`** once clarifications are done ([story-kickoff](story-kickoff.md))
- Each logical implementation batch (feature slice, schema + validator, tests, doc alignment)
- After addressing code-review feedback (with review file strikethrough updates per below)
Use **small, coherent commits** rather than one large dump at the end. Match [linear-git-naming](linear-git-naming.md) when a ticket applies.
**When not to commit yet:** exploratory spikes with **no** story branch or ticket; ambiguous scope before kickoff clarifications; or the user explicitly asked you to **leave changes uncommitted** for this session.
## Never push

View File

@ -5,7 +5,7 @@ alwaysApply: true
# Git workflow (Neon Sprawl)
**Agent:** You may **`git commit`** at your discretion while on story/ticket work; do **not** **`git push`** except when the user explicitly asks (see [commit-and-review](commit-and-review.md) **Never push** and **Opening a PR when the user asks**).
**Agent:** On story/ticket work, **`git commit` as you go** on the story branch—do **not** wait for an explicit “commit” from the user (see [commit-and-review](commit-and-review.md) **Commit as you go**). Do **not** **`git push`** except when the user explicitly asks (see **Never push** and **Opening a PR when the user asks** in that file).
- **Beginning work on a new Linear issue** — Create a **new branch** as soon as story work starts (planning, implementation, or both). **Branch names must start with the Linear issue id** (e.g. `NEO-6-position-state-api`); see [linear-git-naming](linear-git-naming.md). Stay on that branch for everything scoped to that issue, including **`docs/plans/{KEY}-implementation-plan.md`**, until the story is merged. Do not put ticketed story plans only on `main` while implementation lives on a branch.
- **Branching from `main` requires a fresh pull first** — before creating a new story branch from `main`, run `git fetch origin`, `git checkout main`, and `git pull --ff-only` so the branch starts from the latest remote `main`. Do not branch from a stale local `main`.

View File

@ -13,7 +13,7 @@ Optional **personas** for Cursor. Enable by **@ mentioning** the rule in chat or
| **Code review** | [`.cursor/rules/code-review-agent.md`](.cursor/rules/code-review-agent.md) | PR / diff / pre-merge review; **always writes** `docs/reviews/YYYY-MM-DD-{slug}.md` with **Documentation checked** vs `docs/plans/` and `docs/decomposition/modules/`; **when suggestions are fixed, strike through those bullets + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); short chat pointer |
| **Docs review** | [`.cursor/rules/docs-review-agent.md`](.cursor/rules/docs-review-agent.md) | Coherence / links / dev-guide fitness for `docs/` (especially `docs/game-design/` + decomposition); **writes** `docs/reviews/YYYY-MM-DD-{slug}.md`; **when suggestions are fixed, strike through + `Done.` in that file** ([planning-implementation-docs](.cursor/rules/planning-implementation-docs.md)); use when working in **documents** or @ mention |
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Automated tests (AAA, mandatory):** **C#** — every new or changed **`[Fact]` / `[Theory]`** method in **`*Tests.cs`** must use full **Arrange → Act → Assert**: the three **`// Arrange` / `// Act` / `// Assert`** labels, **Act** = behavior under test only, **Assert** = all expectations including **`ReadFromJsonAsync`** (etc.) for verification (no blank line required after the phase comments) — [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), [testing-expectations](.cursor/rules/testing-expectations.md); snippets **`xut`** / **`xutc`**. **GdUnit (`client/test/`)** — same AAA with **`# Arrange` / `# Act` / `# Assert`** — [gdscript-style](.cursor/rules/gdscript-style.md#gdunit-test-layout-aaa). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** agents may **commit** at discretion on story/ticket work; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use the **GitHub MCP** **`create_pull_request`** tool (`user-github`) with a title starting **`NEO-123:`** ([commit-and-review](.cursor/rules/commit-and-review.md)); **do not use `gh` CLI for GitHub operations in this repo**. **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
Project-wide conventions live under [`.cursor/rules/`](.cursor/rules/) (many are `alwaysApply`). **Planning / implementation decisions** must be written into `docs/plans/` (and related docs)—[`.cursor/rules/planning-implementation-docs.md`](.cursor/rules/planning-implementation-docs.md). **Manual QA checklists** for user-visible ticketed work are generated during implementation at `docs/manual-qa/{KEY}.md` (same rule). **Automated tests (AAA, mandatory):** **C#** — every new or changed **`[Fact]` / `[Theory]`** method in **`*Tests.cs`** must use full **Arrange → Act → Assert**: the three **`// Arrange` / `// Act` / `// Assert`** labels, **Act** = behavior under test only, **Assert** = all expectations including **`ReadFromJsonAsync`** (etc.) for verification (no blank line required after the phase comments) — [csharp-style](.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert), [testing-expectations](.cursor/rules/testing-expectations.md); snippets **`xut`** / **`xutc`**. **GdUnit (`client/test/`)** — same AAA with **`# Arrange` / `# Act` / `# Assert`** — [gdscript-style](.cursor/rules/gdscript-style.md#gdunit-test-layout-aaa). **Godot client layout** (thin `main.gd`, split by concern): [`.cursor/rules/godot-client-script-organization.md`](.cursor/rules/godot-client-script-organization.md). **Git:** on Linear/story work, agents **commit as they go** on the story branch (kickoff plan, coherent batches)—**do not wait** for the user to say “commit”; **never** `git push` unless the user asks — [`.cursor/rules/commit-and-review.md`](.cursor/rules/commit-and-review.md). **Open PR:** when the user asks, use the **GitHub MCP** **`create_pull_request`** tool (`user-github`) with a title starting **`NEO-123:`** ([commit-and-review](.cursor/rules/commit-and-review.md)); **do not use `gh` CLI for GitHub operations in this repo**. **Story lifecycle:** kickoff [`.cursor/rules/story-kickoff.md`](.cursor/rules/story-kickoff.md); after merge / end of story — `checkout main`, `pull`, delete local story branch — [`.cursor/rules/story-end.md`](.cursor/rules/story-end.md). **Linear:** on **end story**, use **`AskQuestion`** multiple choice for **In Test** / **Done** / **Skip** when Agent mode supports it ([story-end](.cursor/rules/story-end.md) §4a); otherwise ask in prose. Wait for the choice (or the same message naming the state) **before** `save_issue`; do not guess. **PR / push text:** no “Made-with: Cursor” boilerplate (same file).
**Commits tied to a Linear issue** must start the subject with the **issue id** and a colon (e.g. `NEO-8: …`). Branch naming and exceptions (`chore:` when there is no ticket) — [`.cursor/rules/linear-git-naming.md`](.cursor/rules/linear-git-naming.md).

View File

@ -6,9 +6,12 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|------|---------|
| [`schemas/`](schemas/) | JSON Schema files (`*.schema.json`) |
| [`skills/`](skills/) | Skill catalogs; each row matches [`schemas/skill-def.schema.json`](schemas/skill-def.schema.json) — **stable `id`**, **`allowedXpSourceKinds`** for [E2.M1](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md) / [E2.M2](../docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
| [`mastery/`](mastery/) | Mastery catalogs; each file matches [`schemas/mastery-catalog.schema.json`](schemas/mastery-catalog.schema.json) — **stable `branchId` / `perkId`**, tier gates via skill level ([E2.M3](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md)) |
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
**Validate locally** (same as PR gate):

View File

@ -0,0 +1,54 @@
{
"schemaVersion": 1,
"perks": {
"salvage_scrap_efficiency_1": {
"id": "salvage_scrap_efficiency_1",
"displayName": "Scrap Sifter",
"effectKind": "gather_yield_modifier"
},
"salvage_bulk_haul_1": {
"id": "salvage_bulk_haul_1",
"displayName": "Haul Frame",
"effectKind": "gather_carry_modifier"
}
},
"tracks": [
{
"skillId": "salvage",
"tiers": [
{
"tierIndex": 1,
"requiredLevel": 2,
"branches": [
{
"branchId": "scrap_efficiency",
"displayName": "Scrap Efficiency",
"perkIds": []
},
{
"branchId": "bulk_haul",
"displayName": "Bulk Haul",
"perkIds": []
}
]
},
{
"tierIndex": 2,
"requiredLevel": 4,
"branches": [
{
"branchId": "scrap_efficiency",
"displayName": "Scrap Efficiency",
"perkIds": ["salvage_scrap_efficiency_1"]
},
{
"branchId": "bulk_haul",
"displayName": "Bulk Haul",
"perkIds": ["salvage_bulk_haul_1"]
}
]
}
]
}
]
}

View File

@ -0,0 +1,121 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/mastery-catalog.schema.json",
"title": "MasteryCatalog",
"description": "Mastery tracks and perk definitions for content/mastery/*_mastery.json. Perk and branch ids are stable forever—rename displayName only. See docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md.",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "perks", "tracks"],
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"perks": {
"type": "object",
"description": "Map of perkId to PerkDef. Keys must match each PerkDef.id.",
"additionalProperties": {
"$ref": "#/$defs/PerkDef"
}
},
"tracks": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/MasteryTrack"
}
}
},
"$defs": {
"stableId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"PerkDef": {
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName"],
"properties": {
"id": {
"$ref": "#/$defs/stableId",
"description": "Immutable perk key for unlock state and telemetry."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating unlock data."
},
"effectKind": {
"type": "string",
"minLength": 1,
"description": "Optional stub for future gameplay hooks (E3+). No apply in E2.M3 core."
}
}
},
"MasteryBranch": {
"type": "object",
"additionalProperties": false,
"required": ["branchId", "perkIds"],
"properties": {
"branchId": {
"$ref": "#/$defs/stableId",
"description": "Immutable branch key; mutually exclusive pick per tier."
},
"displayName": {
"type": "string",
"minLength": 1
},
"perkIds": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/$defs/stableId"
},
"description": "Perks unlocked when this branch is chosen at this tier (may be empty at tier 1)."
}
}
},
"MasteryTier": {
"type": "object",
"additionalProperties": false,
"required": ["tierIndex", "requiredLevel", "branches"],
"properties": {
"tierIndex": {
"type": "integer",
"minimum": 1,
"description": "1-based tier index; stable key for PerkState branch picks (NEO-47+)."
},
"requiredLevel": {
"type": "integer",
"minimum": 1,
"description": "Skill level from E2.M2 required before this tier's branches are eligible."
},
"branches": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/$defs/MasteryBranch"
}
}
}
},
"MasteryTrack": {
"type": "object",
"additionalProperties": false,
"required": ["skillId", "tiers"],
"properties": {
"skillId": {
"$ref": "#/$defs/stableId",
"description": "Must exist in E2.M1 SkillDef catalog."
},
"tiers": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/MasteryTier"
}
}
}
}
}
}

View File

@ -37,9 +37,10 @@ Provide a data-driven **non-combat skill** layer: `SkillDef` registry, **skill**
### E2.M3 - MasteryAndPerkUnlocks
- Responsibility: Mastery tracks and perk unlock state for **skill** build expression beyond raw levels.
- Key contracts: `MasteryTrack`, `PerkUnlockEvent`, `PerkState`
- Dependencies: E2.M2
- Key contracts: `MasteryTrack`, `PerkDef`, `PerkUnlockEvent`, `PerkState`
- Dependencies: E2.M2, E2.M1 (catalog `skillId` validation)
- Stage target: Pre-production
- **Linear (Slice 4 — E2.M3):** [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) (label **`E2.M3`**); flagship **`salvage`** track; table and ordering in [E2.M3 — MasteryAndPerkUnlocks](../modules/E2_M3_MasteryAndPerkUnlocks.md) and [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md).
### E2.M4 - ProgressionPacingControls
@ -80,8 +81,9 @@ Provide a data-driven **non-combat skill** layer: `SkillDef` registry, **skill**
### Slice 4 - Mastery and pacing (pre-production)
- Scope: E2.M3 and E2.M4 for first mastery branch and global pacing policy.
- Dependencies: E2.M2, E9.M2
- **Linear (E2.M3):** [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) — see [E2.M3 module](../modules/E2_M3_MasteryAndPerkUnlocks.md). **E2.M4** pacing backlog is separate (depends on E9.M2).
- Scope: E2.M3 for first **salvage** mastery branch; E2.M4 for global pacing policy when E9.M2 is available.
- Dependencies: E2.M2 (E2.M3); E2.M2 + E9.M2 (E2.M4)
- Acceptance criteria:
- At least one mastery path unlocks a perk without blocking other skills.
- Pacing policy can be toggled for test cohorts without redeploy where possible.

View File

@ -41,7 +41,9 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
## CI (prototype)
**Skill catalogs:** the repo **PR gate** workflow ([`.github/workflows/pr-gate.yml`](../../../.github/workflows/pr-gate.yml)) runs [`scripts/validate_content.py`](../../../scripts/validate_content.py) on every push and pull request. It validates each object in `content/skills/*.json` under the top-level `skills` array against [`content/schemas/skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) (Python **jsonschema**, Draft 2020-12), rejects **duplicate `id`** across files, and (Slice 1 / [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) enforces the **frozen prototype trio** ids plus **gather + tech + (process or make)** category coverage. Extend the script when additional catalogs and schemas ship.
**Skill catalogs:** the repo **PR gate** workflow ([`.github/workflows/pr-gate.yml`](../../../.github/workflows/pr-gate.yml)) runs [`scripts/validate_content.py`](../../../scripts/validate_content.py) on every push and pull request. It validates each object in `content/skills/*.json` under the top-level `skills` array against [`content/schemas/skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) (Python **jsonschema**, Draft 2020-12), rejects **duplicate `id`** across files, and (Slice 1 / [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) enforces the **frozen prototype trio** ids plus **gather + tech + (process or make)** category coverage.
**Mastery catalogs ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45)):** the same script validates `content/mastery/*_mastery.json` against [`content/schemas/mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json), cross-checks track `skillId` against loaded `SkillDef` ids, rejects **duplicate `perkId`**, enforces **branch integrity** (tier branch sets match; `perkIds` reference defined perks), and (Slice 4) requires **exactly one** track for **`salvage`** only. Extend the script when additional catalogs and schemas ship.
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.

View File

@ -25,7 +25,13 @@
**NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** + **`RefineSkillXpConstants`** + test **`RefineActivityDeniedRegistryWebApplicationFactory`** — craft/refine success is intended to award **`refine`** XP (**`sourceKind: activity`**, **10** XP) via the same **`SkillProgressionGrantOperations`** stack as NEO-38 ([implementation plan](../../plans/NEO-42-implementation-plan.md)); manual QA **[`NEO-42.md`](../../manual-qa/NEO-42.md)**; **[server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)**. **E3.M2** must invoke **`GrantOnSuccessfulCraftOrRefine`** on real **`CraftResult`** success for end-to-end behavior (no craft route in repo at this slice).
**Still backlog within this module:** Slice 3 **NEO-43** (and **NEO-44** gig path); **NEO-42** end-to-end wiring awaits **E3.M2** success handler. Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge.
**NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** for scripted **`mission_reward`** skill XP ([implementation plan](../../plans/NEO-43-implementation-plan.md)); manual QA **[`NEO-43.md`](../../manual-qa/NEO-43.md)**; full quest hand-in awaits **E7.M2**.
**Still backlog within E2.M2 / Slice 3:** **NEO-44** (gig XP from combat, Epic 5 — not skill XP via E2.M2). **NEO-42** end-to-end craft wiring awaits **E3.M2** success handler.
**E2.M3:** [MasteryAndPerkUnlocks](E2_M3_MasteryAndPerkUnlocks.md) re-evaluates perk eligibility on skill **level-up** after grants ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)).
Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as stories merge.
## Purpose

View File

@ -6,38 +6,138 @@
|--------|--------|
| **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** | Planned (see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI 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.
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 players **skill level** reaches **`requiredLevel`**, that tiers 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 branchs 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)
When [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) kicks off, mirror the Python CI gates documented in [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). Consider **`tierIndex`** uniqueness and sequential 1..N per track at server boot (schema allows gaps today).
## 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
- Progress `MasteryTrack` data; emit `PerkUnlockEvent` and persist `PerkState`.
- 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 | Role |
|----------|------|
| `MasteryTrack` | Branch progress per design. |
| `PerkUnlockEvent` | Unlocks for UI and combat/craft hooks. |
| `PerkState` | Current unlocked perks per character. |
| 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](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)
- Gameplay systems that gate abilities or recipes on perks (integration per content).
- **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** — at least one mastery path unlocks a perk without blocking other skills.
- **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 sites ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)); align with [E9.M1](E9_M1_TelemetryEventSchema.md) when catalog exists.
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 2.
- 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)

View File

@ -51,6 +51,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| E1.M3 | In Progress | **NEO-23 landed:** JSON v1 targeting read + select (`GET`/`POST …/target`, `Game/Targeting/`, [NEO-23](../../plans/NEO-23-implementation-plan.md)) — `PlayerTargetStateResponse` / soft lock / `reasonCode` denials; wire name differs from illustrative **`TargetState`** in module doc (called out in plan + README). **NEO-24 landed:** client tab-target + lock HUD synced to server ([NEO-24](../../plans/NEO-24-implementation-plan.md)) — `TargetSelectionClient` ([`client/scripts/target_selection_client.gd`](../../../client/scripts/target_selection_client.gd)), Tab / Esc bindings, `TargetLockLabel` HUD, hybrid movement-triggered refresh via new `PositionAuthorityClient.authoritative_ack` signal (fires on every server-confirmed position, boot + `move-stream` 200) with a 250 ms cooldown; manual QA in [`docs/manual-qa/NEO-24.md`](../../manual-qa/NEO-24.md). **NEO-25 landed:** versioned **`GET /game/world/interactables`** ([NEO-25](../../plans/NEO-25-implementation-plan.md)) — `InteractablesListResponse` / `InteractablesWorldApi` in `server/NeonSprawl.Server/Game/Interaction/`; Godot `interactables_catalog_client.gd` + `interactable_world_builder.gd` (fetch-driven props + glow); [server README — Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25). **NEO-26 landed:** prototype **`SelectionEvent`** — **`TargetSelectionClient.selection_event`** ([NEO-26](../../plans/NEO-26-implementation-plan.md)) when **`lockedTargetId`** changes; optional **`log_selection_events`**. **NEO-27 landed:** Slice 3 telemetry hook sites documented — `selection_event` maps to product `target_changed` in `target_selection_client.gd`; server lock transition hook comments in `InMemoryPlayerTargetLockStore`; reserved `ability_cast_requested` / `ability_cast_denied` comments in `client/scripts/main.gd` (all TODO(E9.M1)); see [NEO-27](../../plans/NEO-27-implementation-plan.md) and [`docs/manual-qa/NEO-27.md`](../../manual-qa/NEO-27.md). **Follow-on / still open:** richer **`InteractableDescriptor`** consumers beyond list projection + existing `POST …/interact`; production telemetry ingest/catalog after E9.M1. **Precursor (E1.M1):** [NEO-9](../../plans/NEO-9-implementation-plan.md) `POST …/interact`. | Linear [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24)[NEO-27](https://linear.app/neon-sprawl/issue/NEO-27); [E1_M3_InteractionAndTargetingLayer.md](E1_M3_InteractionAndTargetingLayer.md); [E1M3 prototype backlog](../../plans/E1M3-prototype-backlog.md); [server README — Targeting](../../../server/README.md#targeting-neo-23), [Interactable descriptors (NEO-25)](../../../server/README.md#interactable-descriptors-neo-25), [Interaction](../../../server/README.md#interaction-neo-9) |
| E1.M4 | In Progress | **NEO-29 landed:** prototype `HotbarLoadout` v1 server contract (`GET`/`POST /game/players/{id}/hotbar-loadout`) with stable deny reason codes, per-player scope, and persistence policy matching NEO-8/NS-17 (Postgres when configured, in-memory fallback otherwise). Client boot hydration is wired via `hotbar_loadout_client.gd` + `hotbar_state.gd` from `main.gd`; manual QA checklist created. **NEO-31 landed:** prototype **`POST /game/players/{id}/ability-cast`**, digit-key cast wiring from `main.gd`, `ability_cast_client.gd`, and `hotbar_cast_slot_resolver.gd` (target id from `lockedTargetId` in cached target state); GdUnit covers resolver + HTTP client; manual QA [NEO-31](../../manual-qa/NEO-31.md). **NEO-28 landed:** cast POST validates **lock + registry + range** (`invalid_target`, `out_of_range`); **`AbilityCastClient.cast_result_received`** + **`CastFeedbackLabel`** HUD line; manual QA [NEO-28](../../manual-qa/NEO-28.md). **NEO-30 landed:** Slice 3 cast funnel telemetry **hook-site comments** in [`AbilityCastApi.cs`](../../../server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs) (`ability_cast_requested` on authoritative accept, `ability_cast_denied` + non-empty `reasonCode` on each JSON deny branch); client dev hooks unchanged; manual QA [NEO-30](../../manual-qa/NEO-30.md); plan [NEO-30](../../plans/NEO-30-implementation-plan.md). **NEO-32 landed:** `GET /game/players/{id}/cooldown-snapshot` + `on_cooldown` cast deny + prototype global cooldown commit; [`cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`cooldown_state.gd`](../../../client/scripts/cooldown_state.gd), **`CooldownSlotsLabel`** in [`main.gd`](../../../client/scripts/main.gd); manual QA [NEO-32](../../manual-qa/NEO-32.md); plan [NEO-32](../../plans/NEO-32-implementation-plan.md). | [NEO-29](../../plans/NEO-29-implementation-plan.md), [NEO-31](../../plans/NEO-31-implementation-plan.md), [NEO-28](../../plans/NEO-28-implementation-plan.md), [NEO-30](../../plans/NEO-30-implementation-plan.md), [NEO-32](../../plans/NEO-32-implementation-plan.md); [E1_M4_AbilityInputScaffold.md](E1_M4_AbilityInputScaffold.md); [E1M4 prototype backlog](../../plans/E1M4-prototype-backlog.md); `server/NeonSprawl.Server/Game/AbilityInput/`; [`client/scripts/hotbar_loadout_client.gd`](../../../client/scripts/hotbar_loadout_client.gd), [`client/scripts/hotbar_state.gd`](../../../client/scripts/hotbar_state.gd), [`client/scripts/ability_cast_client.gd`](../../../client/scripts/ability_cast_client.gd), [`client/scripts/hotbar_cast_slot_resolver.gd`](../../../client/scripts/hotbar_cast_slot_resolver.gd), [`client/scripts/cooldown_snapshot_client.gd`](../../../client/scripts/cooldown_snapshot_client.gd), [`client/scripts/cooldown_state.gd`](../../../client/scripts/cooldown_state.gd); [server README — Hotbar loadout](../../../server/README.md#hotbar-loadout-neo-29), [Ability cast (NEO-31)](../../../server/README.md#ability-cast-neo-31), [Cooldown snapshot (NEO-32)](../../../server/README.md#cooldown-snapshot-neo-32) |
| E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) |
| E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog at [`content/mastery/prototype_salvage_mastery.json`](../../../content/mastery/prototype_salvage_mastery.json) + schema [`mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, unknown `skillId`, duplicate `perkId`, branch integrity, and Slice 4 **salvage-only** track gate; designer note + freeze table in [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) + [`content/README.md`](../../../content/README.md). **Still planned:** server load ([NEO-46](https://linear.app/neon-sprawl/issue/NEO-46)), 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), [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-37NEO-41, NEO-42NEO-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/` |

View File

@ -29,12 +29,14 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| E2.M1 | SkillDefinitionRegistry | None | SkillDef, SkillCategory, UnlockRequirement, allowedXpSourceKinds | Prototype | In Progress |
| E2.M2 | XpAwardAndLevelEngine | E2.M1 | XpGrantEvent, LevelCurve, LevelUpEvent | Prototype | In Progress |
| E2.M3 | MasteryAndPerkUnlocks | E2.M2 | MasteryTrack, PerkUnlockEvent, PerkState | Pre-production | Planned |
| E2.M3 | MasteryAndPerkUnlocks | E2.M1, E2.M2 | MasteryTrack, PerkDef, PerkUnlockEvent, PerkState | Pre-production | In Progress |
| E2.M4 | ProgressionPacingControls | E2.M2, E9.M2 | XpModifierProfile, CatchUpRule, PacingPolicy | Pre-production | Planned |
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). **NEO-33** (content + CI + docs) moves the register row to **In Progress**; **NEO-34+** cover server registry and HTTP. Update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when slices land.
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Slice 3 integration: [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)[NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) landed; **NEO-44** (gig XP, Epic 5) remains backlog. Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
**E2.M3 note:** Epic 2 Slice 4 — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49); label **`E2.M3`**. **NEO-45 landed:** prototype **`salvage`** mastery catalog + schema + CI (`content/mastery/`, `scripts/validate_content.py`); register row **In Progress**. Flagship track; dependency order and story text in [E2_M3_MasteryAndPerkUnlocks.md](E2_M3_MasteryAndPerkUnlocks.md) and [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md). **NEO-47** blocked by [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (skill level source).
### Epic 3 — Crafting Economy

View File

@ -63,7 +63,7 @@ Names below are **v1** after a theme pass; abilities and lore can still rename a
**Open:**
- **Curves:** same philosophy as **skills** (soft feel, more XP per level)—per-gig tables vs global template **TBD**.
- **Mastery/perks** on gigs vs flat level gates—**open** (E2.M3-style expression).
- **Mastery/perks** on gigs vs flat level gates—**open** (branch/perk depth **analogous** to [E2.M3 skill mastery](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md), **not** the E2.M3 module itself—gig trees stay a **separate** system per [skills.md](skills.md)).
## Hub swap

View File

@ -0,0 +1,164 @@
# E2.M3 — Pre-production story backlog (MasteryAndPerkUnlocks)
Working backlog for **Epic 2 — Slice 4** ([mastery and pacing](../decomposition/epics/epic_02_skills_and_progression.md#slice-4---mastery-and-pacing-pre-production)). Decomposition and contracts: [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md).
**Labels (Linear):** every issue **`E2.M3`**; add **`server`** / **`Feature`** as listed per issue.
**Precursor (do not re-scope):** skill **level** authority lives in **E2.M2** ([E2.M2 module](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md)); `SkillDef` ids from **E2.M1**. **Gig** perks are **not** `SkillDef` mastery.
**Flagship skill:** **`salvage`** (gather loop — [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)).
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
| Slug | Linear |
|------|--------|
| E2M3-01 | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45/e2m3-lock-prototype-salvage-mastery-catalog-schemas-ci) |
| E2M3-02 | [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast) |
| E2M3-03 | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence) |
| E2M3-04 | [NEO-48](https://linear.app/neon-sprawl/issue/NEO-48/e2m3-perk-state-get-branch-selection-post-bruno) |
| E2M3-05 | [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49/e2m3-perk-unlock-telemetry-hook-sites) |
**Dependency graph in Linear:** E2M3-02 blocked by E2M3-01. E2M3-03 blocked by E2M3-02 and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38). E2M3-04 blocked by E2M3-03. E2M3-05 blocked by E2M3-03 (may parallel E2M3-04).
---
## Story order (recommended)
| Order | Slug | Depends on |
|-------|------|------------|
| 1 | **E2M3-01** | E2.M1 `salvage` `SkillDef` ([NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) |
| 2 | **E2M3-02** | E2M3-01 |
| 3 | **E2M3-03** | E2M3-02, [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (skill level) |
| 4 | **E2M3-04** | E2M3-03 |
| 5 | **E2M3-05** | E2M3-03 (parallel OK with E2M3-04) |
---
### E2M3-01 — Prototype salvage mastery catalog + schemas + CI
**Goal:** Lock content shape and CI validation for one **`salvage`** `MasteryTrack` before any server load.
**In scope**
- `content/schemas/mastery-catalog.schema.json` (or equivalent single-catalog schema).
- `content/mastery/prototype_salvage_mastery.json` with tier 1 @ level 2 (`scrap_efficiency` vs `bulk_haul`) and tier 2 @ level 4 perks.
- `scripts/validate_content.py` checks: unknown `skillId`, duplicate perk ids, branch integrity.
- Module doc designer note (mutually exclusive branches; no respec v1).
**Out of scope**
- Server loader, perk state, HTTP, client HUD.
**Acceptance criteria**
- [x] PR gate validates mastery JSON against schema. (**Done** — NEO-45.)
- [x] Only **`salvage`** has a track; `refine` / `intrusion` not required. (**Done** — NEO-45.)
- [x] Stable `branchId` / `perkId` documented in [E2_M3](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) freeze box. (**Done** — NEO-45.)
---
### E2M3-02 — Server mastery catalog load (fail-fast)
**Goal:** Disk → host: startup load of mastery catalog(s) with cross-check against `ISkillDefinitionRegistry`.
**NEO-46 kickoff:** mirror CI gate constants from [NEO-45 implementation plan](NEO-45-implementation-plan.md) (see E2.M3 **NEO-46 handoff**).
**In scope**
- Loader + `IMasteryCatalogRegistry` under `server/NeonSprawl.Server/Game/Skills/` (or `Game/Mastery/` if split).
- Fail-fast on malformed files, duplicate ids, unknown `skillId`.
- Unit tests (AAA) for loader happy path and failure modes.
**Out of scope**
- Per-player state, unlock logic, HTTP.
**Acceptance criteria**
- [ ] Server refuses boot when catalog invalid (mirror E2.M1 catalog behavior).
- [ ] Registry resolves track by `skillId` and perk metadata by `perkId`.
---
### E2M3-03 — Perk unlock engine + PerkState persistence
**Goal:** Server-authoritative unlock evaluation from **skill level** + branch selection; persist `PerkState`; re-run on level-up.
**In scope**
- `IPlayerPerkStateStore` (in-memory + Postgres + migration), mirroring skill progression stores.
- Unlock engine: tier eligibility, mutually exclusive branch pick, `PerkUnlockEvent` emission.
- Hook after `SkillProgressionGrantOperations` (or dedicated service) on level-up for affected `skillId`.
- Integration/unit tests (AAA): eligibility, select branch, deny invalid pick.
**Out of scope**
- HTTP API, Bruno, client UI.
- Applying `effectKind` in gather/craft/combat.
**Acceptance criteria**
- [ ] Player at salvage level 2 can select one tier-1 branch; other branchs tier-1 perks stay locked.
- [ ] Level 4 unlocks tier-2 perks on the chosen branch path only.
- [ ] Stable `reasonCode` on deny (unknown tier, branch already chosen, level too low, etc.).
---
### E2M3-04 — Perk state HTTP + Bruno
**Goal:** Versioned **GET** snapshot and **POST** branch selection for manual QA and future client.
**In scope**
- `GET /game/players/{id}/perk-state` (path finalized in implementation plan).
- `POST` branch selection endpoint with structured success/deny payloads.
- Bruno `bruno/neon-sprawl-server/perk-state/`.
- `server/README.md` section; API tests (AAA).
**Out of scope**
- Godot HUD (optional future issue).
- Gameplay stat application.
**Acceptance criteria**
- [ ] GET returns unlocked perks + branch picks for prototype salvage track.
- [ ] POST select branch persists and matches engine rules from E2M3-03.
- [ ] Bruno exercises happy path + at least one deny case.
---
### E2M3-05 — `perk_unlock` telemetry hook sites
**Goal:** Comment-only hooks in unlock / branch-select path for future E9.M1 catalog event **`perk_unlock`**.
**In scope**
- Hook placement in unlock engine (and POST path if applicable).
- `TODO(E9.M1)` comments; no production logging.
- README + module doc pointer.
**Out of scope**
- Telemetry ingest, dashboards.
**Acceptance criteria**
- [ ] Hook sites documented in code and `server/README.md`.
- [ ] Matches Epic 2 Slice 4 telemetry vocabulary.
---
## After this backlog
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
- Add `docs/manual-qa/{NEO-XX}.md` when HTTP surfaces land (E2M3-04+).
## Related docs
- [E2_M3_MasteryAndPerkUnlocks.md](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md)
- [E2_M2_XpAwardAndLevelEngine.md](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md)
- [E2_M1_SkillDefinitionRegistry.md](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md)
- [epic_02_skills_and_progression.md](../decomposition/epics/epic_02_skills_and_progression.md)
- [skills.md](../game-design/skills.md)

View File

@ -0,0 +1,122 @@
# NEO-45 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-45 |
| **Title** | E2.M3: Lock prototype salvage mastery catalog + schemas + CI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-45/e2m3-lock-prototype-salvage-mastery-catalog-schemas-ci |
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
| **Branch** | `NEO-45-salvage-mastery-catalog-schemas-ci` |
## Kickoff clarifications
| Topic | Question | Answer |
|--------|----------|--------|
| **PerkDef layout** | Top-level `perks` map vs inline on branches? | **Top-level `perks` map** — tracks reference `perkId` strings (dedupes metadata; mirrors skill catalog style). |
| **Tier 1 unlocks** | Perks on branch pick at level 2? | **Branch pick only** — tier-1 branches have **empty `perkIds`**; first perks unlock at tier 2 (`requiredLevel` 4). |
| **Frozen `perkId` strings** | Who names tier-2 perks? | **Agent proposal** (below); change `displayName` only after ship. |
| **WIP decomposition docs** | Include uncommitted E2.M3 doc edits on this branch? | **Yes** — commit with kickoff/plan work. |
| **NEO-33 dependency** | Blocker status? | **Satisfied**`salvage` / `refine` / `intrusion` frozen in [`content/skills/prototype_skills.json`](../../content/skills/prototype_skills.json) (NEO-33 Done). |
### Frozen prototype ids (Slice 4 — salvage flagship)
| Kind | Value | Notes |
|------|--------|--------|
| `skillId` | `salvage` | Only skill with a `MasteryTrack` in prototype catalog. |
| Tier 1 `branchId` | `scrap_efficiency`, `bulk_haul` | Mutually exclusive pick @ `requiredLevel` **2**; **no** perks yet. |
| Tier 2 `branchId` | same ids | Continuation paths @ `requiredLevel` **4**; one perk each. |
| Tier 2 `perkId` | `salvage_scrap_efficiency_1`, `salvage_bulk_haul_1` | **Immutable** after ship; tune player text via `displayName`. |
## Goal, scope, and out-of-scope
**Goal:** Lock the **salvage** flagship mastery catalog under `content/mastery/` with JSON Schema and CI validation (`scripts/validate_content.py`) so tiers, branches, and perk ids are stable before server load (NEO-46).
**In scope (from Linear):**
- `content/schemas/mastery-catalog.schema.json` validates the prototype catalog shape.
- `content/mastery/prototype_salvage_mastery.json` — one **`MasteryTrack`** for **`salvage`** (tier 1 @ level 2 branches; tier 2 @ level 4 perks).
- `scripts/validate_content.py` — unknown `skillId`, duplicate `perkId`, branch/perk reference integrity.
- Designer note: mutually exclusive branch picks per tier; no respec in v1 (E2.M3 module + `content/README.md`).
- Include WIP E2.M3 decomposition / backlog doc updates on this branch.
**Out of scope (from Linear):**
- Server boot load (NEO-46).
- Perk unlock engine, HTTP, client HUD (NEO-47+).
- Separate mastery XP bar; gig perks; respec.
- `refine` / `intrusion` mastery rows (CI must **not** require them).
## Acceptance criteria checklist
- [x] `content/schemas/mastery-catalog.schema.json` (or equivalent) validates prototype catalog.
- [x] `content/mastery/prototype_salvage_mastery.json` ships one **MasteryTrack** for `salvage` only (tier 1 @ level 2: `scrap_efficiency` vs `bulk_haul`; tier 2 @ level 4 with one perk per branch path).
- [x] CI fails on unknown `skillId`, duplicate perk ids, or invalid branch references.
- [x] Designer note in module doc: mutually exclusive branch picks per tier; no respec in v1.
- [x] `refine` / `intrusion` have **no** mastery rows yet (CI does not require them).
## Technical approach
1. **Schema (`mastery-catalog.schema.json`):** Draft 2020-12 object with `schemaVersion: 1`, top-level **`perks`** map (keys = perk `id`), and **`tracks`** array. Each track: `skillId`, ordered **`tiers`** (`tierIndex`, `requiredLevel`, `branches`). Each branch: `branchId`, optional `displayName`, `perkIds` (array, may be empty). Each perk: `id`, `displayName`, optional `effectKind` stub string. Enforce `snake_case` patterns for ids; `additionalProperties: false` on objects.
2. **Prototype catalog:** Single file `content/mastery/prototype_salvage_mastery.json` with exactly **one** track (`salvage`), **two** tiers, **two** branches per tier (same `branchId` values tier-to-tier), **two** perks in `perks` map referenced only from tier 2. Stub `effectKind` values (e.g. `gather_yield_modifier`) for future E3 wiring — no gameplay apply.
3. **`validate_content.py`:** After existing skill + level-curve validation, load skill ids from validated `*_skills.json` files, then validate `content/mastery/*_mastery.json` against mastery schema. Post-schema gates:
- **Unknown `skillId`:** track `skillId` ∉ loaded skill ids.
- **Duplicate `perkId`:** same id in `perks` map or referenced twice across branches.
- **Branch integrity:** every `perkIds[]` entry exists in `perks`; tier 2 `branchId` set equals tier 1 `branchId` set; `requiredLevel` strictly increases by tier; unique `branchId` per tier.
- **Slice 4 gate:** exactly **one** track; its `skillId` is **`salvage`** only (no `refine` / `intrusion` tracks).
4. **Docs:** Extend `content/README.md` with `mastery/` paths and freeze reminder. Ensure E2.M3 module freeze box lists concrete `perkId` values. Update CT.M1 CI prototype paragraph. Refresh `documentation_and_implementation_alignment.md` E2.M3 row. Commit included WIP decomposition/backlog edits.
5. **No server/C#/client changes** in this story.
## Files to add
| Path | Purpose |
|------|---------|
| `content/schemas/mastery-catalog.schema.json` | JSON Schema for mastery catalog files (`perks` + `tracks`). |
| `content/mastery/prototype_salvage_mastery.json` | Prototype **salvage** `MasteryTrack`, tier/branch tree, and perk definitions. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Validate mastery catalogs; cross-check `skillId`; duplicate `perkId`; branch/perk integrity; Slice 4 salvage-only gate. |
| `content/README.md` | Document `content/mastery/`, schema path, Slice 4 freeze, local validate command. |
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Confirm designer note + freeze table with concrete `perkId` values (WIP edits). |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph: mastery catalog validation. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 / NEO-45 row after catalog lands. |
| `docs/decomposition/epics/epic_02_skills_and_progression.md` | Slice 4 pointer consistency (WIP edits). |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | E2.M3 cross-link (WIP edits). |
| `docs/decomposition/modules/module_dependency_register.md` | E2.M3 note (WIP edits). |
| `docs/plans/E2M3-pre-production-backlog.md` | Pre-production backlog (untracked → tracked). |
## Tests
| Item | Coverage |
|------|----------|
| **CI / PR gate** | `.github/workflows/pr-gate.yml` already runs `python scripts/validate_content.py` — extend script; no workflow change required unless gate message needs updating. |
| **Manual negative cases** | Temporarily break catalog (unknown `skillId`, duplicate `perkId`, orphan `perkIds` reference, extra `refine` track) and confirm non-zero exit + actionable stderr. |
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python scripts/validate_content.py` reports mastery + existing catalogs OK. |
| **Dedicated pytest** | **Not required** for NEO-45 (same as NEO-33 / NEO-39 content-only pattern); optional follow-up if we want fixture-based script tests later. |
| **Server tests** | **None** — server load is NEO-46. |
## Open questions / risks
- **Tier index convention:** Use explicit `tierIndex` (1-based) in JSON for stable server persistence keys in NEO-47; document in schema description.
- **Catalog growth:** Slice 4 gate mirrors NEO-33 — relaxing “salvage only” requires a new Linear issue to change `validate_content.py` constants.
- **Drift vs NEO-46:** Server loader must mirror Python rules; NEO-46 plan should reference this plans gate constants and validate **`tierIndex`** uniqueness / sequential 1..N per track (schema allows gaps today).
- **None** blocking at kickoff after clarifications above.
## Decisions
| Decision | Rationale |
|----------|-----------|
| Top-level `perks` map | User kickoff choice; avoids duplicating perk metadata on every branch reference. |
| Empty tier-1 `perkIds` | User kickoff choice; matches Linear AC (“tier 2 … one perk per branch path”). |
| Frozen tier-2 perk ids `salvage_scrap_efficiency_1`, `salvage_bulk_haul_1` | Prefix with skill + branch semantics; unique globally for future multi-track catalogs. |
| Include WIP E2.M3 docs on branch | User kickoff choice; keeps decomposition aligned with Slice 4 backlog before implementation PR. |
## Verification (implementation)
- `python3 scripts/validate_content.py` — exit 0; reports 1 mastery catalog + 1 salvage track.
- `python3 scripts/check_decomposition_language.py` — exit 0.
- Negative cases (restored catalog after each): unknown `skillId`, orphan `perkId`, duplicate `perkId` reference, Slice 4 multi-track gate — all non-zero exit with actionable stderr.

View File

@ -0,0 +1,70 @@
# Code review — NEO-45 (salvage mastery catalog + schemas + CI)
**Date:** 2026-05-16
**Scope:** Branch `NEO-45-salvage-mastery-catalog-schemas-ci`, commit `f5f04d8` (`NEO-45: lock prototype salvage mastery catalog, schemas, and CI.`). Issue **NEO-45**.
**Base:** `main` (parent of `f5f04d8`)
## Verdict
**Approve with nits**
## Summary
The change delivers NEO-45 as a **content-only** slice: `mastery-catalog.schema.json`, prototype `prototype_salvage_mastery.json` (one **salvage** track, tier 1 branch pick @ level 2 with empty `perkIds`, tier 2 perks @ level 4), extended `validate_content.py` with schema validation, `skillId` cross-check, perk map key/`id` alignment, duplicate `perkId` / reference guards, branch-set continuity across tiers, strictly increasing `requiredLevel`, and a Slice 4 **salvage-only / exactly-one-track** gate. Documentation (E2.M3 module, CT.M1, epic Slice 4, alignment table, implementation plan, backlog) is thorough and matches kickoff decisions. Risk is low: no server/C#/client surface; PR gate already runs the script. Main gap is **status field drift** between the alignment row (**In Progress**) and the register / E2.M3 Summary (**Planned**).
## Documentation checked
| Document | Assessment |
|----------|------------|
| `docs/plans/NEO-45-implementation-plan.md` | **Matches** — schema shape, catalog layout, validator gates, frozen ids, out-of-scope (NEO-46+), verification steps align with the implementation. |
| `docs/plans/E2M3-pre-production-backlog.md` | **Matches** — E2M3-01 acceptance criteria marked done (NEO-45). |
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | **Matches** — data contract (`perks` map + `tracks`), designer note, freeze table, **In Progress** status. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — CI paragraph documents mastery validation alongside skills. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E2.M3 row aligned with register and module **In Progress**. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E2.M3 **In Progress**, Depends On **E2.M1, E2.M2**, NEO-45 landed in note. |
| `docs/decomposition/epics/epic_02_skills_and_progression.md` | **Matches** — Slice 4 scope, NEO-45→49 chain, E2.M4 separation. |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | **Matches** — NEO-43 landed line + E2.M3 level-up hook cross-link. |
| `content/README.md` | **Matches** — Slice 4 freeze reminder and links to E2.M3. |
**Register / tracking:** ~~Update **Planned → In Progress** for E2.M3~~ **Done** (register, module Summary, backlog checkboxes).
## Blocking issues
_None._
## Suggestions
1. ~~**Align E2.M3 status across docs** — `documentation_and_implementation_alignment.md` marks E2.M3 **In Progress** with NEO-45 landed, but `module_dependency_register.md` and `E2_M3_MasteryAndPerkUnlocks.md` Summary still say **Planned**. Pick one convention (recommended: **In Progress** on register + module Summary when catalog merges, matching E2.M1 after NEO-33).~~ **Done.** Register + module Summary → **In Progress**; Depends On → **E2.M1, E2.M2**; E2.M3 note cites NEO-45 landed.
2. ~~**Backlog checkboxes** — In `docs/plans/E2M3-pre-production-backlog.md`, flip E2M3-01 acceptance criteria to `[x]` (or add a “Done in NEO-45” note) so the backlog does not contradict `NEO-45-implementation-plan.md`.~~ **Done.**
3. ~~**Optional CI gate: unreferenced perks** — `perks` map entries with no `perkIds` reference currently pass validation (orphan perk → exit 0). Consider rejecting dead `PerkDef` rows in a follow-up if designers should not ship unused defs; not required by NEO-45 AC.~~ **Done.** `validate_content.py` rejects unreferenced `perks` map entries per file.
4. ~~**NEO-46 parity** — Plan already flags mirroring Python gate constants in C# loader; when implementing NEO-46, also consider validating **`tierIndex`** uniqueness / sequential 1..N per track (schema allows gaps today; catalog is correct).~~ **Done.** Noted in `NEO-45-implementation-plan.md` open questions for NEO-46.
## Nits
- ~~**Nit:** Tier 2 branches in `prototype_salvage_mastery.json` omit `displayName` while tier 1 includes it; valid per schema but slightly uneven for authoring.~~ **Done.** Tier 2 branches include `displayName`.
- ~~**Nit:** `global_referenced_perk_ids` treats a perk as globally single-use across branches/files — correct for mutually exclusive v1 trees, but document if a future catalog ever needs the same `perkId` on two branches (unlikely).~~ **Done.** Comment in `validate_content.py`.
- **Nit:** Adding a second **salvage** track with duplicated perk references fails on duplicate reference errors before the Slice 4 “exactly one track” message; harmless, but authors adding a second track with no perk refs get the clearer Slice 4 error.
## Verification
Run before merge:
```bash
pip install -r scripts/requirements-content.txt # if needed
python3 scripts/validate_content.py
python3 scripts/check_decomposition_language.py
```
Manual negative cases (restore catalog after each):
- Unknown `skillId` → non-zero, actionable stderr.
- Unknown `perkIds` reference → non-zero.
- Duplicate perk reference across branches → non-zero.
- Second `salvage` track (no perk refs) → Slice 4 gate non-zero.
No `dotnet test` or Godot run required for this story (content-only per plan).

View File

@ -0,0 +1,65 @@
# Documentation review — NEO-45 (E2.M3 salvage mastery catalog + schemas + CI)
**Date:** 2026-05-16
**Scope:** E2.M3 Slice 4 documentation and content contract for [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45): `docs/plans/NEO-45-implementation-plan.md`, `docs/plans/E2M3-pre-production-backlog.md`, `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`, related Epic 2 / CT.M1 / alignment / register / `content/README.md`, and hybrid-progression ground truth (`docs/game-design/skills.md`, `progression.md`, `gigs.md`).
**Base:** Workspace as of 2026-05-16; `python3 scripts/validate_content.py` and `python3 scripts/check_decomposition_language.py` both exit 0.
## Verdict
**Approve**
## Summary
NEO-45 documentation is coherent with shipped content: implementation plan, E2.M3 module (freeze table, **`perks` map** data contract, **In Progress** status, NEO-46 handoff), CT.M1, `content/README.md`, alignment/register rows, and E2M3-01 backlog checkboxes align with the **salvage-only** prototype track and CI gates. Hybrid vocabulary is preserved (skill mastery vs gig progression; `gigs.md` clarifies gig mastery is separate from E2.M3).
## Documentation checked
| Document | Result |
|----------|--------|
| `docs/plans/NEO-45-implementation-plan.md` | **Matches** — kickoff decisions, frozen ids, technical approach, acceptance checklist, and verification steps align with `content/mastery/prototype_salvage_mastery.json`, `content/schemas/mastery-catalog.schema.json`, and `scripts/validate_content.py` behavior. |
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | **Matches** — freeze table, designer note, **`perks` map** / `tiers[]` shape, **In Progress** status, NEO-46 handoff, NEO-45 acceptance footnote. |
| `docs/plans/E2M3-pre-production-backlog.md` | **Matches** — E2M3-01 acceptance criteria marked done (NEO-45); E2M3-02 points at NEO-45 gate constants. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — mastery paragraph matches script gates (schema, `skillId`, duplicate `perkId`, branch integrity, salvage-only Slice 4). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E2.M3 row correctly states NEO-45 landed and follow-on NEO-4649 planned. |
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E2.M3 **In Progress**, Depends On **E2.M1, E2.M2**, NEO-45 landed in note. |
| `docs/decomposition/epics/epic_02_skills_and_progression.md` | **Matches** — E2.M3 / Slice 4 pointers, skill-vs-gig XP split, and salvage flagship scope. |
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | **Matches** — E2.M3 cross-link for level-up re-evaluation; no mastery XP bar implied. |
| `content/README.md` | **Matches**`mastery/` row, Slice 4 freeze, validate command; anchors target E2.M3 designer + freeze headings. |
| `content/schemas/mastery-catalog.schema.json` | **Matches** — documents E2.M3 module; `schemaVersion`, `perks`, `tracks`, `tierIndex`, stable id patterns. |
| `content/mastery/prototype_salvage_mastery.json` | **Matches** — one `salvage` track; tier 1 empty `perkIds`; tier 2 perks + branch `displayName`; frozen ids per module freeze table. |
| `docs/game-design/skills.md` | **Matches** — Mastery and perks section points to E2.M3; respec open; gig tree separate. |
| `docs/game-design/progression.md` | **Matches** — skill vs gig vocabulary; no mastery XP conflation. |
| `docs/game-design/gigs.md` | **Matches** — gig mastery **open**; line clarifies analogy to skill branch/perk depth, not E2.M3 module. |
| `docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md` | **N/A** — no mastery-specific change required; `salvage` id exists for cross-check. |
| `scripts/validate_content.py` | **Matches** — docs describe gates implemented in `_validate_mastery_catalogs` / `_prototype_slice4_gate`. |
## Blocking issues
(none)
## Suggestions
1. ~~**E2.M3 module + register status** — Set **Status** to **In Progress** in `E2_M3_MasteryAndPerkUnlocks.md` (summary table) and `module_dependency_register.md` (E2.M3 row), mirroring E2.M1 after NEO-33 and the alignment doc **In Progress** row. Reserve **Done** for when NEO-49 closes the Slice 4 engine/HTTP/telemetry slice.~~ **Done.**
2. ~~**E2M3-pre-production-backlog E2M3-01 checkboxes** — Mark the three E2M3-01 acceptance criteria `[x]` when NEO-45 is merged/Done in Linear, or add a one-line note under E2M3-01: “Catalog + CI: **NEO-45** (see implementation plan).”~~ **Done.**
3. ~~**E2.M3 Data contract — catalog shape** — Add a short subsection (or extend **Data contract**) documenting the shipped JSON layout: top-level **`perks`** map (keys = `perkId`), **`tracks`** array, **`MasteryTrack.tiers[]`** with `tierIndex` / `requiredLevel` / `branches[]` / `perkIds[]`. Link `content/schemas/mastery-catalog.schema.json` and note map-key === `PerkDef.id` (enforced in CI).~~ **Done.**
4. ~~**NEO-46 plan handoff** — When kicking off NEO-46, reference NEO-45 plan **gate constants** (salvage-only track count, branch-set equality tier-to-tier) so server loader parity stays explicit (already listed as a risk in the NEO-45 plan).~~ **Done.** E2.M3 **NEO-46 handoff** + E2M3-02 backlog pointer; NEO-45 plan open questions.
5. ~~**`gigs.md` gig-mastery line** — Clarify “E2.M3-style expression” means **analogous branch/perk depth for gigs**, not the E2.M3 **skill** module; optional cross-link to a future gig-mastery epic/module when named.~~ **Done.**
## Nits
- ~~Tier 2 branches in `prototype_salvage_mastery.json` omit `displayName` while tier 1 includes it — valid per schema; optional designer note that `displayName` is optional per branch row.~~ **Done.** Catalog + designer note.
- ~~Module **Acceptance criteria (E2.M3 / Slice 4)** bullets describe **runtime** unlock behavior (NEO-47+); consider a footnote that NEO-45 satisfies **data + CI** only.~~ **Done.**
## Verification
- `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — expect `content OK` with 1 mastery catalog and 1 mastery track.
- `python3 scripts/check_decomposition_language.py` — exit 0.
- After doc edits: grep `E2.M3` status in `module_dependency_register.md` vs `documentation_and_implementation_alignment.md` for consistency.
- Open `content/README.md` links to E2.M3 `#designer-note-tiers-branches-and-level-gates` and `#prototype-slice-4-freeze--salvage-flagship-neo-45` in the GitHub UI (anchor fragility check).
- Manual negative cases from NEO-45 plan (unknown `skillId`, duplicate `perkId`, extra track) — confirm stderr messages remain actionable.

View File

@ -4,6 +4,7 @@
Validates:
- skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json
- mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json
"""
from __future__ import annotations
@ -17,11 +18,16 @@ from jsonschema import Draft202012Validator
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json"
MASTERY_SCHEMA = REPO_ROOT / "content/schemas/mastery-catalog.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
# Slice 4 prototype lock (NEO-45): exactly one salvage track across all mastery files.
PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage"
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
"""Return a human-readable error if Slice 1 contract fails, else None."""
@ -45,6 +51,184 @@ def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, s
return None
def _validate_mastery_catalogs(
*,
mastery_files: list[Path],
mastery_validator: Draft202012Validator,
skill_ids: frozenset[str],
) -> tuple[int, list[str]]:
"""Validate mastery JSON files. Returns (error_count, track_skill_ids across all files)."""
errors = 0
all_track_skill_ids: list[str] = []
global_perk_ids: dict[str, str] = {}
# Each perkId may appear in at most one branch reference catalog-wide (v1 mutually exclusive trees).
global_referenced_perk_ids: dict[str, str] = {}
for path in mastery_files:
rel = str(path.relative_to(REPO_ROOT))
data = json.loads(path.read_text(encoding="utf-8"))
schema_errors = 0
for err in sorted(mastery_validator.iter_errors(data), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} {loc}: {err.message}", file=sys.stderr)
schema_errors += 1
errors += 1
if schema_errors > 0:
continue
perks = data.get("perks")
tracks = data.get("tracks")
if not isinstance(perks, dict) or not isinstance(tracks, list):
print(f"error: {rel}: expected top-level 'perks' object and 'tracks' array", file=sys.stderr)
errors += 1
continue
referenced_perk_ids_in_file: set[str] = set()
for perk_key, perk in perks.items():
if not isinstance(perk, dict):
print(f"error: {rel}: perks[{perk_key!r}] must be an object", file=sys.stderr)
errors += 1
continue
pid = perk.get("id")
if not isinstance(pid, str):
continue
if perk_key != pid:
print(
f"error: {rel}: perks map key {perk_key!r} must match PerkDef.id {pid!r}",
file=sys.stderr,
)
errors += 1
prev = global_perk_ids.get(pid)
if prev:
print(f"error: duplicate perk id {pid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
global_perk_ids[pid] = rel
for ti, track in enumerate(tracks):
if not isinstance(track, dict):
print(f"error: {rel}: tracks[{ti}] must be an object", file=sys.stderr)
errors += 1
continue
skill_id = track.get("skillId")
if isinstance(skill_id, str):
all_track_skill_ids.append(skill_id)
if skill_id not in skill_ids:
print(
f"error: {rel}: tracks[{ti}].skillId {skill_id!r} is not a known SkillDef id "
f"(known: {sorted(skill_ids)!r})",
file=sys.stderr,
)
errors += 1
tiers = track.get("tiers")
if not isinstance(tiers, list):
continue
prev_required_level = 0
prev_tier_branch_ids: frozenset[str] | None = None
for tier_i, tier in enumerate(tiers):
if not isinstance(tier, dict):
continue
required_level = tier.get("requiredLevel")
if isinstance(required_level, int) and required_level <= prev_required_level:
print(
f"error: {rel}: tracks[{ti}].tiers[{tier_i}].requiredLevel must be strictly "
f"greater than previous tier ({prev_required_level})",
file=sys.stderr,
)
errors += 1
if isinstance(required_level, int):
prev_required_level = required_level
branches = tier.get("branches")
if not isinstance(branches, list):
continue
tier_branch_ids: list[str] = []
for bi, branch in enumerate(branches):
if not isinstance(branch, dict):
continue
branch_id = branch.get("branchId")
if isinstance(branch_id, str):
if branch_id in tier_branch_ids:
print(
f"error: {rel}: tracks[{ti}].tiers[{tier_i}] duplicate branchId "
f"{branch_id!r}",
file=sys.stderr,
)
errors += 1
tier_branch_ids.append(branch_id)
perk_ids = branch.get("perkIds")
if not isinstance(perk_ids, list):
continue
for perk_id in perk_ids:
if not isinstance(perk_id, str):
continue
if perk_id not in perks:
print(
f"error: {rel}: tracks[{ti}].tiers[{tier_i}].branches[{bi}] "
f"references unknown perkId {perk_id!r}",
file=sys.stderr,
)
errors += 1
prev_ref = global_referenced_perk_ids.get(perk_id)
if prev_ref:
print(
f"error: duplicate perk id reference {perk_id!r} in {prev_ref} and {rel}",
file=sys.stderr,
)
errors += 1
else:
global_referenced_perk_ids[perk_id] = (
f"{rel} tracks[{ti}].tiers[{tier_i}].branches[{bi}]"
)
referenced_perk_ids_in_file.add(perk_id)
tier_branch_set = frozenset(tier_branch_ids)
if prev_tier_branch_ids is not None and tier_branch_set != prev_tier_branch_ids:
print(
f"error: {rel}: tracks[{ti}].tiers[{tier_i}] branchId set "
f"{sorted(tier_branch_set)!r} must match previous tier "
f"{sorted(prev_tier_branch_ids)!r}",
file=sys.stderr,
)
errors += 1
if tier_branch_ids:
prev_tier_branch_ids = tier_branch_set
for perk_key in perks:
if isinstance(perk_key, str) and perk_key not in referenced_perk_ids_in_file:
print(
f"error: {rel}: perks[{perk_key!r}] is not referenced by any branch perkIds",
file=sys.stderr,
)
errors += 1
return errors, all_track_skill_ids
def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1:
return (
"error: prototype Slice 4 expects exactly one MasteryTrack across all mastery files, "
f"got {len(track_skill_ids)} track(s) with skillIds {track_skill_ids!r}"
)
if track_skill_ids[0] != PROTOTYPE_SLICE4_SALVAGE_SKILL_ID:
return (
"error: prototype Slice 4 expects the sole track skillId "
f"{PROTOTYPE_SLICE4_SALVAGE_SKILL_ID!r}, got {track_skill_ids[0]!r}"
)
return None
def main() -> int:
if not SKILL_SCHEMA.is_file():
print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr)
@ -52,11 +236,16 @@ def main() -> int:
if not LEVEL_CURVE_SCHEMA.is_file():
print(f"error: missing schema {LEVEL_CURVE_SCHEMA}", file=sys.stderr)
return 1
if not MASTERY_SCHEMA.is_file():
print(f"error: missing schema {MASTERY_SCHEMA}", file=sys.stderr)
return 1
skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema)
level_curve_schema = json.loads(LEVEL_CURVE_SCHEMA.read_text(encoding="utf-8"))
level_curve_validator = Draft202012Validator(level_curve_schema)
mastery_schema = json.loads(MASTERY_SCHEMA.read_text(encoding="utf-8"))
mastery_validator = Draft202012Validator(mastery_schema)
if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -72,6 +261,15 @@ def main() -> int:
print(f"error: no *_level_curve.json files under {SKILLS_DIR}", file=sys.stderr)
return 1
if not MASTERY_DIR.is_dir():
print(f"error: missing directory {MASTERY_DIR}", file=sys.stderr)
return 1
mastery_files = sorted(MASTERY_DIR.glob("*_mastery.json"))
if not mastery_files:
print(f"error: no *_mastery.json files under {MASTERY_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {}
errors = 0
@ -188,11 +386,27 @@ def main() -> int:
print(slice1_err, file=sys.stderr)
return 1
mastery_errors, track_skill_ids = _validate_mastery_catalogs(
mastery_files=mastery_files,
mastery_validator=mastery_validator,
skill_ids=frozenset(seen_ids.keys()),
)
if mastery_errors:
print(f"content validation failed with {mastery_errors} error(s)", file=sys.stderr)
return 1
slice4_err = _prototype_slice4_gate(track_skill_ids)
if slice4_err:
print(slice4_err, file=sys.stderr)
return 1
print(
"content OK: "
f"{len(skill_files)} skill catalog file(s), "
f"{len(curve_files)} level curve file(s), "
f"{len(seen_ids)} unique skill id(s)"
f"{len(mastery_files)} mastery catalog file(s), "
f"{len(seen_ids)} unique skill id(s), "
f"{len(track_skill_ids)} mastery track(s)"
)
return 0