Merge pull request #79 from ViPro-Technologies/NEO-45-salvage-mastery-catalog-schemas-ci
NEO-45: Lock prototype salvage mastery catalog, schemas, and CIpull/80/head
commit
4ede4798d9
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue