diff --git a/.cursor/rules/commit-and-review.md b/.cursor/rules/commit-and-review.md index 7bc7b84..d119048 100644 --- a/.cursor/rules/commit-and-review.md +++ b/.cursor/rules/commit-and-review.md @@ -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 diff --git a/.cursor/rules/git-workflow.md b/.cursor/rules/git-workflow.md index c9ce127..0b7d8b9 100644 --- a/.cursor/rules/git-workflow.md +++ b/.cursor/rules/git-workflow.md @@ -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`. diff --git a/AGENTS.md b/AGENTS.md index ab415c8..688e047 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/content/README.md b/content/README.md index 7f45a75..e171a27 100644 --- a/content/README.md +++ b/content/README.md @@ -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): diff --git a/content/mastery/prototype_salvage_mastery.json b/content/mastery/prototype_salvage_mastery.json new file mode 100644 index 0000000..ea8165d --- /dev/null +++ b/content/mastery/prototype_salvage_mastery.json @@ -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"] + } + ] + } + ] + } + ] +} diff --git a/content/schemas/mastery-catalog.schema.json b/content/schemas/mastery-catalog.schema.json new file mode 100644 index 0000000..9bc2ab3 --- /dev/null +++ b/content/schemas/mastery-catalog.schema.json @@ -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" + } + } + } + } + } +} diff --git a/docs/decomposition/epics/epic_02_skills_and_progression.md b/docs/decomposition/epics/epic_02_skills_and_progression.md index 877534d..b497aa4 100644 --- a/docs/decomposition/epics/epic_02_skills_and_progression.md +++ b/docs/decomposition/epics/epic_02_skills_and_progression.md @@ -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. diff --git a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md index 91c30b7..0a0dd9a 100644 --- a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md +++ b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md @@ -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. diff --git a/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md b/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md index 6222c3b..ef4f2b0 100644 --- a/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md +++ b/docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md @@ -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 diff --git a/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md b/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md index faffe5c..c4ddeb5 100644 --- a/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md +++ b/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md @@ -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 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) + +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) diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 1f2bed5..f1bcce7 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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-37–NEO-41, NEO-42–NEO-44 | | E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` | diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 9065803..2a619ca 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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 diff --git a/docs/game-design/gigs.md b/docs/game-design/gigs.md index 8e716e9..7f2cf1e 100644 --- a/docs/game-design/gigs.md +++ b/docs/game-design/gigs.md @@ -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 diff --git a/docs/plans/E2M3-pre-production-backlog.md b/docs/plans/E2M3-pre-production-backlog.md new file mode 100644 index 0000000..b4dd546 --- /dev/null +++ b/docs/plans/E2M3-pre-production-backlog.md @@ -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 branch’s 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) diff --git a/docs/plans/NEO-45-implementation-plan.md b/docs/plans/NEO-45-implementation-plan.md new file mode 100644 index 0000000..0b0f23f --- /dev/null +++ b/docs/plans/NEO-45-implementation-plan.md @@ -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 plan’s 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. diff --git a/docs/reviews/2026-05-16-NEO-45-code-review.md b/docs/reviews/2026-05-16-NEO-45-code-review.md new file mode 100644 index 0000000..a1b4150 --- /dev/null +++ b/docs/reviews/2026-05-16-NEO-45-code-review.md @@ -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). diff --git a/docs/reviews/2026-05-16-NEO-45.md b/docs/reviews/2026-05-16-NEO-45.md new file mode 100644 index 0000000..ad5315b --- /dev/null +++ b/docs/reviews/2026-05-16-NEO-45.md @@ -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-46–49 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. diff --git a/scripts/validate_content.py b/scripts/validate_content.py index e17006e..e001328 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -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