Compare commits

...

57 Commits

Author SHA1 Message Date
VinPropane da0562bdf4
Merge pull request #86 from ViPro-Technologies/NEO-51-server-item-catalog-load-fail-fast
NEO-51: Server loads item catalog at startup (fail-fast)
2026-05-17 22:11:49 -04:00
VinPropane 38f0c56711 NEO-51: Address code review — tests, docs, review strikethroughs.
Add Slice 1 role/stackMax and missing-path loader tests; E3.M3 server
load bullet; register footnote; host test temp-dir cleanup.
2026-05-17 22:04:03 -04:00
VinPropane 1c9c93513e NEO-51: Add code review for item catalog fail-fast load. 2026-05-17 22:01:47 -04:00
VinPropane c2366eed4c NEO-51: Guard item catalog startup log for CA1873.
Skip LogInformation argument evaluation when Information is disabled.
2026-05-17 22:00:27 -04:00
VinPropane 0a3c28ae5c NEO-51: Primary constructor on ItemDefinitionCatalog; test tidy.
Restore explicit Content:ItemsDirectory in test factory; use const
for schema-violation fixture JSON.
2026-05-17 21:58:53 -04:00
VinPropane d23f6dd8ab NEO-51: Document item catalog boot and mark plan complete.
README, alignment register, and acceptance checklist updated.
2026-05-17 21:56:12 -04:00
VinPropane 2ba129dca1 NEO-51: Fail-fast item catalog load at server startup.
Mirror NEO-34/validate_content.py: *_items.json schema validation,
Slice 1 gate, ItemDefinitionCatalog DI, loader/host tests, Bruno health smoke.
2026-05-17 21:56:09 -04:00
VinPropane b461f3bf47 NEO-51: Record kickoff clarification answers in plan.
User confirmed strict NEO-52 split and loader plus host boot tests.
2026-05-17 21:52:15 -04:00
VinPropane b5674c4150 NEO-51: Add implementation plan for item catalog fail-fast load.
Kickoff clarifications adopt strict NEO-52 split, NEO-34 test pattern,
and in-process validation mirroring validate_content.py.
2026-05-17 21:50:50 -04:00
VinPropane 37761a4a32 NEO-51: Require agent recommendation in kickoff clarifications.
Story kickoff and planning docs now mandate a stated recommendation
(with rationale) for every clarification question and open-question row.
2026-05-17 21:49:46 -04:00
VinPropane afa39c48af
Merge pull request #85 from ViPro-Technologies/NEO-50-itemdef-starter-set-schemas-ci
NEO-50: Lock prototype ItemDef starter set + schemas + CI
2026-05-17 21:45:53 -04:00
VinPropane 02b6b3a460 NEO-50: Remove unreachable duplicate prototypeRole check. 2026-05-17 21:41:26 -04:00
VinPropane 0ce952131a NEO-50: Address review — equip_stub gate by role, backlog sync. 2026-05-17 21:29:32 -04:00
VinPropane 8fa828daa0 NEO-50: Add code review — Approve for prototype item catalog + CI. 2026-05-17 21:27:59 -04:00
VinPropane bbc6b20fd3 NEO-50: Document frozen prototype item spine and update alignment. 2026-05-17 21:25:06 -04:00
VinPropane 923ae0511c NEO-50: Add item-def schema, prototype catalog, and CI gates. 2026-05-17 21:25:06 -04:00
VinPropane 6616bfbca5 NEO-50: Add implementation plan for prototype ItemDef catalog + CI. 2026-05-17 21:22:55 -04:00
VinPropane fa70e1bd6e
Merge pull request #84 from ViPro-Technologies/docs/e3m3-prototype-backlog
NEO-50: E3.M3 prototype backlog and decomposition doc links
2026-05-17 21:18:20 -04:00
VinPropane 56233bfe2c NEO-50: Add E3.M3 prototype backlog and decomposition doc links.
Documents Epic 3 Slice 1 story order (NEO-50–NEO-56), frozen prototype item ids,
and register/module pointers for ItemizationAndInventorySchema.
2026-05-17 21:15:42 -04:00
VinPropane 1ad04f05cd
Merge pull request #83 from ViPro-Technologies/NEO-48-perk-state-get-branch-selection-post-bruno
NEO-48: perk state GET + branch selection POST + Bruno
2026-05-17 21:08:11 -04:00
VinPropane 00c5937872 NEO-48: revert WhenWritingNull on skill-progression grant API
Keep omit-null reasonCode only on new perk-state responses; restoring
the existing grant endpoint wire format (reasonCode: null on success).
2026-05-17 20:59:37 -04:00
VinPropane 12e52bccc9 NEO-48: fix in-memory TrySetSkillXpTotal zero on absent skill
Setting XP to 0 is a no-op success when no row exists, matching Postgres DELETE
semantics and TrySetSkillXpTotals behavior.
2026-05-17 20:46:55 -04:00
VinPropane aa83790703 NEO-48: omit null reasonCode from success JSON responses
WhenWritingNull on PerkBranchSelectResponse and SkillProgressionGrantResponse
matches hotbar/cast/interaction; fixes Bruno tier1 undefined check.
2026-05-17 20:46:28 -04:00
VinPropane e9c02eded7 NEO-48: mark review items resolved — Approve verdict 2026-05-17 20:38:02 -04:00
VinPropane aba26446d3 NEO-48: address r2 review — atomic fixture, tests, docs
Batch skillXp then resetPerkState with XP rollback; 400 for invalid skillXp;
MasteryFixtureApi gate tests; README fixture vs grant semantics; Bruno GET playerId.
2026-05-17 20:37:00 -04:00
VinPropane 2bb02d76e9 NEO-48: add re-review for mastery fixture testing refactor 2026-05-17 20:34:36 -04:00
VinPropane ffdbed26f2 NEO-48: dev mastery-fixture API for Bruno state setup
Replace fixture player ids with POST …/__dev/mastery-fixture (resetPerkState,
absolute skillXp). Bruno deny tests set state in pre-request on dev-local-1.
Gated to Development/Testing or Game:EnableMasteryFixtureApi.
2026-05-17 20:31:26 -04:00
VinPropane b72201b1f0 NEO-48: Bruno fixture players for deterministic perk deny tests
Seed neo48-perk-fresh and neo48-perk-branch via Game:FixturePlayerIds;
level_too_low uses isolated fresh player, branch_already_chosen sets up
neo48-perk-branch in pre-request instead of skipping on shared state.
2026-05-17 20:26:21 -04:00
VinPropane f4c3fe86e4 NEO-48: skip level_too_low Bruno when dev player already progressed
Pre-request checks salvage level and tier-1 pick; bru.skipRequest avoids
false failure after branch_already_chosen or skill-progression grants.
2026-05-17 20:23:42 -04:00
VinPropane d5b65c94f6 NEO-48: fix Bruno pre-request baseUrl for branch_already_chosen
Use getEnvVar fallback and axios like other collection scripts; Invalid URL
when baseUrl was undefined in pre-request.
2026-05-17 20:22:22 -04:00
VinPropane 3ba8300652 NEO-48: address code review — Bruno deny, API deny tests
Add self-contained branch_already_chosen Bruno request, HTTP deny smoke
for unknown track/tier/branch and null-body 400, folder docs for level_too_low.
2026-05-17 20:21:42 -04:00
VinPropane 0838e661c9 NEO-48: add code review for perk-state HTTP 2026-05-17 20:16:46 -04:00
VinPropane 39c54c3881 NEO-48: perk-state HTTP, Bruno, tests, and docs
GET/POST /game/players/{id}/perk-state with versioned JSON, engine-backed
branch selection, PerkStateApiTests, Bruno collection, README, and E2.M3 alignment.
2026-05-17 20:10:31 -04:00
VinPropane 5290044845 NEO-48: add implementation plan for perk-state HTTP
Kickoff plan for GET/POST /game/players/{id}/perk-state, Bruno, and API
tests; records user choices on route path and unlockedEvents on success.
2026-05-17 20:06:52 -04:00
VinPropane 1c102af15e
Merge pull request #82 from ViPro-Technologies/NEO-49-perk-unlock-telemetry-hook-sites
NEO-49: perk_unlock telemetry hook sites
2026-05-17 19:28:45 -04:00
VinPropane 5058bc1963 NEO-49: address code review follow-ups for telemetry hooks 2026-05-17 19:21:21 -04:00
VinPropane 7ce9dc172c NEO-49: add code review for perk_unlock telemetry hooks 2026-05-17 19:15:17 -04:00
VinPropane 38ef54c238 NEO-49: document perk_unlock hook sites in README, module, and manual QA 2026-05-17 19:10:14 -04:00
VinPropane 340dbbe60d NEO-49: add comment-only perk_unlock telemetry hook in TryUnlockPerks 2026-05-17 19:10:12 -04:00
VinPropane 0d63965724 chore: gitignore VS Code C# Dev Tools auto-generated runners 2026-05-17 19:09:10 -04:00
VinPropane f3e48f4077 NEO-49: kickoff implementation plan for perk_unlock telemetry hooks 2026-05-17 19:08:54 -04:00
VinPropane 01a02da923
Merge pull request #81 from ViPro-Technologies/NEO-47-perk-unlock-engine-perkstate-persistence
NEO-47: Perk unlock engine + PerkState persistence
2026-05-17 19:00:18 -04:00
VinPropane 76e710b491 NEO-47: re-review — follow-ups verified, approve for merge 2026-05-17 18:46:25 -04:00
VinPropane 0a2a6fe45b NEO-47: address code review follow-ups for perk unlock engine
Retroactive path-auto unlock on branch pick, unlock rollback, CanWritePlayer
gate, expanded deny tests, gather-hook smoke, decomposition docs, review strikethroughs.
2026-05-17 18:45:11 -04:00
VinPropane 2520a80b6e NEO-47: add code review for perk unlock engine 2026-05-17 18:42:28 -04:00
VinPropane 38774a5b39 NEO-47: perk unlock engine, PerkState persistence, and tests
Add V004 perk tables, in-memory/Postgres stores, PerkUnlockEngine (tier-1 branch
pick, path-auto tier-2 unlocks), level-up hook in skill XP grants, and AAA tests.
Bruno docs note internal perk reevaluation on level-up (HTTP in NEO-48).
2026-05-17 18:38:41 -04:00
VinPropane e297b3a023 NEO-47: add implementation plan for perk unlock engine
Kickoff clarifications, tier-2 auto-path recommendation, and file/test lists for PerkState persistence and level-up hooks.
2026-05-17 18:34:50 -04:00
VinPropane 619146da85
Merge pull request #80 from ViPro-Technologies/NEO-46-mastery-catalog-startup-load
NEO-46: Server loads mastery catalog at startup (fail-fast)
2026-05-17 18:28:19 -04:00
VinPropane 28bafaf77d chore: enforce commit-as-you-go via rule, AGENTS.md, and stop hook.
Add alwaysApply commit-as-you-go checklist, Critical git commits in AGENTS.md,
and a stop hook that follow-ups when NEO-* branches end with a dirty tree.
2026-05-17 18:22:46 -04:00
VinPropane 07fc566e2c NEO-46: address code review follow-up.
Add loader deny-path and registry enumeration tests, tierIndex 1..N gate
in validate_content.py, E2.M3 handoff doc update, and review strikethroughs.
2026-05-17 18:12:39 -04:00
VinPropane 174c390aa4 NEO-46: fail-fast mastery catalog load at server startup.
Mirror validate_content.py gates in MasteryCatalogLoader, expose IMasteryCatalogRegistry, cross-check skillIds against the skill catalog, and enforce tierIndex 1..N per track.
2026-05-17 18:06:50 -04:00
VinPropane 898f935b4f NEO-46: add implementation plan for mastery catalog boot load. 2026-05-17 18:03:30 -04:00
VinPropane 4ede4798d9
Merge pull request #79 from ViPro-Technologies/NEO-45-salvage-mastery-catalog-schemas-ci
NEO-45: Lock prototype salvage mastery catalog, schemas, and CI
2026-05-17 18:00:06 -04:00
VinPropane 594287c199 NEO-45: address docs review suggestions for E2.M3 Slice 4.
Expand data contract and NEO-46 handoff, gigs.md gig-mastery clarification,
acceptance footnote, and mark docs review items Done.
2026-05-17 13:16:49 -04:00
VinPropane 4a9730c577 NEO-45: address code review doc alignment and CI nits.
Align E2.M3 In Progress status, backlog checkboxes, perks map doc;
reject unreferenced PerkDef rows; tier-2 branch displayNames; review Done.
2026-05-16 20:05:51 -04:00
VinPropane 1f6fe86a1c chore: require commit-as-you-go on story work in agent rules.
Override ambiguous global guidance; AGENTS.md, commit-and-review, and
git-workflow now tell agents to commit on the story branch without waiting
for an explicit "commit" request.
2026-05-16 20:02:08 -04:00
VinPropane f5f04d8258 NEO-45: lock prototype salvage mastery catalog, schemas, and CI.
Add mastery JSON schema and salvage-only Slice 4 catalog with tier/branch
freeze; extend validate_content.py for skillId, perkId, and branch gates;
update E2.M3 decomposition, content README, and implementation plan.
2026-05-16 19:50:09 -04:00
120 changed files with 7693 additions and 51 deletions

11
.cursor/hooks.json 100644
View File

@ -0,0 +1,11 @@
{
"version": 1,
"hooks": {
"stop": [
{
"command": ".cursor/hooks/check-story-branch-clean.sh",
"loop_limit": 2
}
]
}
}

View File

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Cursor stop hook (Neon Sprawl): on NEO-* story branches, nag when the working tree is dirty.
# See .cursor/rules/commit-as-you-go.md and commit-and-review.md.
set -euo pipefail
input=$(cat)
status="completed"
if command -v jq >/dev/null 2>&1; then
status=$(echo "$input" | jq -r '.status // "completed"')
fi
if [[ "$status" != "completed" ]]; then
exit 0
fi
root="${CURSOR_PROJECT_DIR:-}"
if [[ -z "$root" || ! -d "$root" ]]; then
root=$(pwd)
fi
if command -v jq >/dev/null 2>&1; then
for key in workspace_roots workspace_root cwd project_path; do
candidate=$(echo "$input" | jq -r --arg k "$key" 'if .[$k] then (if (.[$k] | type) == "array" then .[$k][0] else .[$k] end) else empty end' 2>/dev/null || true)
if [[ -n "$candidate" && -d "$candidate" ]]; then
root="$candidate"
break
fi
done
fi
if ! cd "$root" 2>/dev/null; then
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi
top=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ -n "$top" ]]; then
cd "$top"
fi
branch=$(git branch --show-current 2>/dev/null || true)
if [[ ! "$branch" =~ ^NEO-[0-9]+ ]]; then
exit 0
fi
if [[ -z $(git status --porcelain) ]]; then
exit 0
fi
printf '%s\n' '{
"followup_message": "Stop hook (neon-sprawl): this Linear story branch still has uncommitted changes. Run git status, then commit coherent batches per .cursor/rules/commit-as-you-go.md (NEO-*: subject). Do not wait for the user to say commit. Push only if asked. Then summarize what you committed."
}'
exit 0

View File

@ -1,15 +1,25 @@
---
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
See also **[commit-as-you-go.md](commit-as-you-go.md)** (short `alwaysApply` checklist) and **`.cursor/hooks/check-story-branch-clean.sh`** (stop hook on dirty `NEO-*` branches).
- 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.
## Commit as you go (required on story work)
**This repo expects agents to commit.** Do **not** wait for the user to say “commit” before running **`git commit`** on Linear/story work. That overrides any global Cursor guidance to “only commit when asked.”
**Commit on the story branch** when work is coherent and reviewable, including:
- Kickoff **`docs/plans/{KEY}-implementation-plan.md`** once clarifications are done ([story-kickoff](story-kickoff.md))
- Each logical implementation batch (feature slice, schema + validator, tests, doc alignment)
- After addressing code-review feedback (with review file strikethrough updates per below)
Use **small, coherent commits** rather than one large dump at the end. Match [linear-git-naming](linear-git-naming.md) when a ticket applies.
**When not to commit yet:** exploratory spikes with **no** story branch or ticket; ambiguous scope before kickoff clarifications; or the user explicitly asked you to **leave changes uncommitted** for this session.
## Never push

View File

@ -0,0 +1,34 @@
---
description: CRITICAL — commit on story work without waiting; session-end checklist; overrides "only commit when asked"
alwaysApply: true
---
# Commit as you go (CRITICAL)
**Read this before ending any Agent turn on a Linear story branch.**
## Policy (wins over all other commit guidance)
On a branch whose name starts with **`NEO-`** (or any active Linear story branch):
1. **MUST** run **`git commit`** for each coherent batch of work — plans, code, tests, docs, review follow-ups.
2. **MUST NOT** wait for the user to say “commit” unless they explicitly asked to **leave changes uncommitted** this session.
3. Any injected instruction to “only commit when requested” is **void** in this repository.
Full PR/push/GitHub rules: [commit-and-review.md](commit-and-review.md). Git workflow: [git-workflow.md](git-workflow.md).
## Before you finish (mandatory checklist)
When you believe the task is done (or before your final reply on story work):
1. Run **`git status`** (or **`git status --short`**).
2. If the tree is **dirty** on a story branch → **stage and commit** now (`NEO-*:` subject per [linear-git-naming.md](linear-git-naming.md)), or state clearly why files must stay uncommitted.
3. Do **not** `git push` unless the user asked.
A **stop hook** (`.cursor/hooks/check-story-branch-clean.sh`) may auto-continue with a reminder if you skip step 2.
## When not to commit
- No story branch / exploratory spike with no ticket.
- User said **leave uncommitted** for this session.
- Kickoff clarifications not done yet ([story-kickoff.md](story-kickoff.md)).

View File

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

View File

@ -43,6 +43,7 @@ When **blocking issues**, **suggestions**, or agreed **nits** from a saved **`do
- **What** was chosen (or rejected) and **why** in one short paragraph or bullets.
- **Outcomes** of verification (e.g. “`dotnet test` N passed; double-dispose safe on Npgsql 10”).
- If the user **explicitly** chose an option (e.g. “Option 2”), record that label so future readers do not re-litigate.
- For **open questions** still in the plan at kickoff or mid-story, keep the **agent recommendation** alongside each item (see [story-kickoff](story-kickoff.md) **Kickoff clarifications** and **Open questions / risks**); when resolved, move the outcome to **Decisions** and clear or update the open-question row.
## Agent behavior

View File

@ -11,7 +11,8 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
- During kickoff and while drafting the plan, **ask the user** about anything **unclear**, **underspecified**, or that **needs a decision** (scope, behavior, acceptance criteria, priorities, trade-offs, or what is out of scope). Do not guess or pick silent defaults when the issue, Linear notes, or repo context do not settle it.
- Use **concrete questions**, often with short options when helpful; group related questions so the user can answer in one pass.
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists.
- For **every** question or blocking decision presented to the user, state the **agents recommendation**: which option to pick (or what to do) and a **one-line rationale** tied to repo precedent, Linear AC, or decomposition docs. Do not list options without a stated preference unless there is genuinely no basis to recommend—in that case, say why.
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists. Put the **agent recommendation** in the chat message that introduces the questions (`AskQuestion` has no recommendation field).
### Clarifications gate (hard requirement)
@ -55,14 +56,14 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
**Required sections** in that file (do **not** ship a plan that omits any of these):
- Story reference (key, title, link if available)
- Kickoff clarifications (questions asked + answers, or explicit “No clarifications needed” with reason)
- Kickoff clarifications — table with **Topic**, **Question**, **Agent recommendation**, **Answer** (or explicit “No clarifications needed” with reason; no table required when zero questions)
- Goal, scope, and out-of-scope (from Linear)
- Acceptance criteria checklist (from Linear)
- Technical approach (concise)
- **Files to add****mandatory:** explicit list of new file paths (or state “none” with one line why)
- **Files to modify****mandatory:** explicit list of paths **with a one-line rationale each** (or state “none” with one line why)
- **Tests****mandatory:** explicit list of test files to **add** or **change**, and what each will cover; if truly no automated tests, say why (e.g. no harness yet) and what manual verification replaces them
- Open questions / risks (if none, write “None.”)
- Open questions / risks — each item: **question or risk**, **agent recommendation**, **status** (`pending` / `adopted` / `deferred`); if none, write “None.”
These three lists (**files to add**, **files to modify**, **tests**) must **always** be generated during kickoff—they are not optional prose and must not be left implicit inside “Technical approach” only.

3
.gitignore vendored
View File

@ -34,3 +34,6 @@ client/addons/gdUnit4/GdUnitRunner.cfg
# VS Code / Cursor — machine-local settings (e.g. Godot editor path)
.vscode/settings.json
# VS Code C# Dev Tools - Auto-generated runners
.vscodecsdt/

View File

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

View File

@ -0,0 +1,25 @@
meta {
name: GET health (item catalog boot NEO-51)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-51 loads content/items/*_items.json at startup (fail-fast). No item HTTP API in this story — use this request to confirm the host started after catalog validation.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: item-catalog
}

View File

@ -0,0 +1,25 @@
meta {
name: GET health (mastery catalog boot NEO-46)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-46 loads content/mastery/*_mastery.json at startup (fail-fast). No mastery HTTP API in this story — use this request to confirm the host started after catalog validation.
}
tests {
test("status 200", function () {
expect(res.getStatus()).to.equal(200);
});
test("service identity", function () {
expect(res.getBody().service).to.equal("NeonSprawl.Server");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: mastery-catalog
}

View File

@ -0,0 +1,28 @@
meta {
name: GET perk state
type: http
seq: 1
}
docs {
NEO-48: per-player perk state read model (branch picks + unlocked perk ids).
Empty state until branch picks; pair with skill-progression grant + POST branch select.
}
get {
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
body: none
auth: none
}
tests {
test("returns 200 JSON schema v1", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId"));
expect(body.branchPicks).to.be.an("array");
expect(body.unlockedPerkIds).to.be.an("array");
});
}

View File

@ -0,0 +1,71 @@
meta {
name: POST branch select deny branch already chosen
type: http
seq: 4
}
docs {
NEO-48: pre-request sets salvage level 2 + tier-1 scrap_efficiency pick via mastery-fixture + perk-state; main request conflicts with bulk_haul.
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const reset = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/mastery-fixture`,
{
schemaVersion: 1,
resetPerkState: true,
skillXp: { salvage: 100 },
},
jsonHeaders,
);
if (reset.status !== 200 || reset.data?.applied !== true) {
throw new Error(`mastery fixture reset failed: ${reset.status} ${JSON.stringify(reset.data)}`);
}
const firstPick = await axios.post(
`${baseUrl}/game/players/${playerId}/perk-state`,
{
schemaVersion: 1,
skillId: "salvage",
tierIndex: 1,
branchId: "scrap_efficiency",
},
jsonHeaders,
);
if (firstPick.status !== 200 || firstPick.data?.selected !== true) {
throw new Error(`tier-1 pick failed: ${firstPick.status} ${JSON.stringify(firstPick.data)}`);
}
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"tierIndex": 1,
"branchId": "bulk_haul"
}
}
tests {
test("denies second pick with branch_already_chosen", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.selected).to.equal(false);
expect(body.reasonCode).to.equal("branch_already_chosen");
expect(body.unlockedEvents).to.be.an("array").that.is.empty;
const salvage = body.perkState.branchPicks.find((t) => t.skillId === "salvage");
expect(salvage).to.be.an("object");
const pick = salvage.picks.find((p) => p.tierIndex === 1);
expect(pick.branchId).to.equal("scrap_efficiency");
});
}

View File

@ -0,0 +1,55 @@
meta {
name: POST branch select deny level too low
type: http
seq: 3
}
docs {
NEO-48: salvage tier 1 requires skill level 2. Pre-request resets dev-local-1 via POST …/__dev/mastery-fixture.
}
script:pre-request {
const axios = require("axios");
const baseUrl = bru.getEnvVar("baseUrl") || bru.getVar("baseUrl");
const playerId = bru.getEnvVar("playerId") || bru.getVar("playerId");
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
const reset = await axios.post(
`${baseUrl}/game/players/${playerId}/__dev/mastery-fixture`,
{
schemaVersion: 1,
resetPerkState: true,
skillXp: { salvage: 0 },
},
jsonHeaders,
);
if (reset.status !== 200 || reset.data?.applied !== true) {
throw new Error(`mastery fixture reset failed: ${reset.status} ${JSON.stringify(reset.data)}`);
}
}
post {
url: {{baseUrl}}/game/players/{{playerId}}/perk-state
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"tierIndex": 1,
"branchId": "scrap_efficiency"
}
}
tests {
test("denies with level_too_low when salvage below gate", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.selected).to.equal(false);
expect(body.reasonCode).to.equal("level_too_low");
expect(body.unlockedEvents).to.be.an("array").that.is.empty;
expect(body.perkState.branchPicks).to.be.an("array").that.is.empty;
});
}

View File

@ -0,0 +1,46 @@
meta {
name: POST branch select tier1
type: http
seq: 2
}
docs {
NEO-48: tier-1 salvage branch pick. Run skill-progression grant (100+ XP for level 2) first if this denies level_too_low.
On success, reasonCode is omitted from JSON (not null); test expects undefined.
}
post {
url: {{baseUrl}}/game/players/dev-local-1/perk-state
body: json
auth: none
}
body:json {
{
"schemaVersion": 1,
"skillId": "salvage",
"tierIndex": 1,
"branchId": "scrap_efficiency"
}
}
tests {
test("selects branch or denies with structured envelope", function () {
expect(res.getStatus()).to.equal(200);
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.selected).to.be.a("boolean");
expect(body.perkState).to.be.an("object");
expect(body.perkState.schemaVersion).to.equal(1);
expect(body.unlockedEvents).to.be.an("array");
if (body.selected) {
expect(body.reasonCode).to.be.undefined;
const salvage = body.perkState.branchPicks.find((t) => t.skillId === "salvage");
expect(salvage).to.be.an("object");
const pick = salvage.picks.find((p) => p.tierIndex === 1);
expect(pick.branchId).to.equal("scrap_efficiency");
} else {
expect(body.reasonCode).to.be.a("string");
}
});
}

View File

@ -0,0 +1,8 @@
meta {
name: perk-state
}
docs {
NEO-48 perk-state HTTP smoke. Deny requests call POST …/__dev/mastery-fixture in pre-request to set salvage XP and clear perk state (Development / Game:EnableMasteryFixtureApi).
Run deny requests (seq 34) before happy-path POST tier1 if you need a clean dev-local-1; deny pre-requests reset shared player state.
}

View File

@ -7,6 +7,8 @@ meta {
docs {
NEO-38: single skill XP grant + allowlist + level-up metadata; see server README.
NEO-40: same contract; server adds comment-only telemetry hook markers (no request/response change).
NEO-47: on level-up, server re-evaluates mastery perk unlocks internally (no JSON change until NEO-48 perk-state HTTP).
Success responses include reasonCode: null (stable since NEO-38); perk-state POST omits reasonCode on success instead.
E2.M2 tracking row in docs/decomposition/modules/documentation_and_implementation_alignment.md lists NEO-40 landed with plan + manual QA links.
}
@ -31,6 +33,7 @@ tests {
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.granted).to.equal(true);
expect(body.reasonCode).to.equal(null);
expect(body.progression).to.be.an("object");
expect(body.progression.schemaVersion).to.equal(1);
expect(body.progression.playerId).to.equal("dev-local-1");

View File

@ -6,9 +6,15 @@ 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)) |
| [`items/`](items/) | Item catalogs; each row matches [`schemas/item-def.schema.json`](schemas/item-def.schema.json) — **stable `id`**, **`stackMax`**, **`inventorySlotKind`** for [E3.M3](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.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 1 — items (NEO-50):** CI expects **exactly six** item rows with ids **`scrap_metal_bulk`**, **`refined_plate_stock`**, **`field_stim_mk0`**, **`survey_drone_kit`**, **`contract_handoff_token`**, **`prototype_armor_shell`** — one row per **`prototypeRole`** (`material` through `equip_stub`). **Do not rename** ids after ship—change **`displayName`** only. See [E3.M3 — Designer note](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#designer-note-stack-slot-and-v1-scope) and [freeze table](../docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md#prototype-slice-1-freeze-neo-50).
**Prototype Slice 4 (NEO-45):** CI expects **exactly one** `MasteryTrack` for **`salvage`** only (`refine` / `intrusion` have no mastery rows yet). Tier 1 @ skill level **2** is a **mutually exclusive** branch pick (`scrap_efficiency` vs `bulk_haul`) with **no** perks; tier 2 @ level **4** unlocks one perk per branch path. **Do not rename** `branchId` / `perkId` after ship—change `displayName` only. See [E2.M3 — Designer note](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#designer-note-tiers-branches-and-level-gates) and [freeze table](../docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md#prototype-slice-4-freeze--salvage-flagship-neo-45).
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
**Validate locally** (same as PR gate):

View File

@ -0,0 +1,47 @@
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "refined_plate_stock",
"displayName": "Refined Plate Stock",
"prototypeRole": "intermediate",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "field_stim_mk0",
"displayName": "Field Stim Mk0",
"prototypeRole": "consumable",
"stackMax": 20,
"inventorySlotKind": "bag"
},
{
"id": "survey_drone_kit",
"displayName": "Survey Drone Kit",
"prototypeRole": "utility",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "contract_handoff_token",
"displayName": "Contract Handoff Token",
"prototypeRole": "quest_token",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "prototype_armor_shell",
"displayName": "Prototype Armor Shell",
"prototypeRole": "equip_stub",
"stackMax": 1,
"inventorySlotKind": "equipment"
}
]
}

View File

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

View File

@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/item-def.json",
"title": "ItemDef",
"description": "Single item row for catalogs (e.g. content/items/*_items.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "prototypeRole", "stackMax", "inventorySlotKind"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable item key for saves, recipes, quests, telemetry, and inventory grants. Never change after content ships; add a new id if the item splits."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"prototypeRole": {
"type": "string",
"description": "Prototype Slice 1 archetype (material through equip_stub). CI requires exactly one row per role.",
"enum": [
"material",
"intermediate",
"consumable",
"utility",
"quest_token",
"equip_stub"
]
},
"stackMax": {
"type": "integer",
"minimum": 1,
"description": "Maximum stack size per inventory slot for this item def."
},
"inventorySlotKind": {
"type": "string",
"description": "Where the item may be stored: general bag vs equipment slot (prototype equip stub).",
"enum": ["bag", "equipment"]
},
"rarity": {
"type": "string",
"description": "Optional rarity band (unused in prototype Slice 1 rows).",
"enum": ["common", "uncommon", "rare", "epic"]
},
"bindPolicy": {
"type": "string",
"description": "Optional bind rule stub (unused in prototype Slice 1 rows).",
"enum": ["none", "bind_on_acquire", "bind_on_equip"]
},
"durabilityMax": {
"type": "integer",
"minimum": 0,
"description": "Optional durability ceiling stub (unused in prototype Slice 1; no durability mutation in v1)."
}
}
}

View File

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

View File

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

View File

@ -41,7 +41,11 @@ 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.
**Item catalogs ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** the same script validates each row in `content/items/*_items.json` against [`content/schemas/item-def.schema.json`](../../../content/schemas/item-def.schema.json), rejects **duplicate `id`** across files, and (Slice 1) enforces the **frozen six-item** id set plus **exactly one row per `prototypeRole`** (`material` through `equip_stub`). Extend the script when additional catalogs and schemas ship.
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.

View File

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

View File

@ -6,38 +6,150 @@
|--------|--------|
| **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; **NEO-46** server load; **NEO-47** perk engine + persistence landed) |
## Linear backlog (Epic 2 Slice 4 — E2.M3)
Issues use label **`E2.M3`**. Dependency order: **NEO-45 → NEO-46 → NEO-47 → NEO-48**; **NEO-49** after **NEO-47** (may parallel **NEO-48**). Canonical story text: [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md).
| Order | Issue | Summary |
|------:|-------|---------|
| 1 | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45/e2m3-lock-prototype-salvage-mastery-catalog-schemas-ci) | Lock prototype **salvage** mastery catalog + schemas + CI |
| 2 | [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast) | Server loads mastery catalog at startup (fail-fast) |
| 3 | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence) | Perk unlock engine + `PerkState` persistence |
| 4 | [NEO-48](https://linear.app/neon-sprawl/issue/NEO-48/e2m3-perk-state-get-branch-selection-post-bruno) | Perk state GET + branch selection POST + Bruno |
| 5 | [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49/e2m3-perk-unlock-telemetry-hook-sites) | `perk_unlock` telemetry hook sites |
`docs/plans/NEO-*-implementation-plan.md` files are added at story kickoff (per [planning-implementation-docs](../../../.cursor/rules/planning-implementation-docs.md)).
## Purpose
Mastery tracks and perk unlock state so **skill** builds have expressive identity beyond raw level curves.
Mastery tracks and perk unlock state so **skill** builds have expressive identity beyond raw level curves from [E2.M2](E2_M2_XpAwardAndLevelEngine.md). **Gig** mastery/perks remain a **separate** system ([`docs/game-design/gigs.md`](../../game-design/gigs.md)).
## v1 design decisions (frozen for backlog)
| Topic | v1 choice |
|-------|-----------|
| Progress into mastery | **Skill level** from E2.M2 (`IPlayerSkillProgressionStore`) — no separate mastery XP bar |
| Branch model | One **`MasteryTrack`** per skill; **tiers** with **mutually exclusive** branch picks per tier |
| Respec | **Out of scope** (late-stage tuning per [`skills.md`](../../game-design/skills.md)) |
| Gig perks | **No** shared tree with gigs |
| Gameplay effects | **State + data only** in E2.M3 core; stat/yield hooks are optional follow-ups (e.g. E3.M1) |
## Data contract (schema)
| Artifact | Path |
|----------|------|
| **JSON Schema** | [`content/schemas/mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json) |
| **Prototype catalog** | [`content/mastery/prototype_salvage_mastery.json`](../../../content/mastery/prototype_salvage_mastery.json) |
Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId` → **`PerkDef`**), and a **`tracks`** array. Branches reference perks by id string only (no inline perk objects). CI requires each **`perks`** map key to equal the nested **`PerkDef.id`** (see [`mastery-catalog.schema.json`](../../../content/schemas/mastery-catalog.schema.json) and [`validate_content.py`](../../../scripts/validate_content.py)).
**`tracks[]` → `MasteryTrack``tiers[]`:** each tier row has **`tierIndex`** (1-based, stable for `PerkState`), **`requiredLevel`** (skill level from E2.M2), and **`branches[]`**. Each branch has **`branchId`**, optional **`displayName`**, and **`perkIds[]`** (may be empty at tier 1).
**`MasteryTrack` fields (prototype):**
| Field | Rule |
|--------|------|
| **`skillId`** | Must exist in [E2.M1](E2_M1_SkillDefinitionRegistry.md) `SkillDef` catalog (`salvage` for flagship slice). |
| **`tiers`** | Ordered list; each tier has **`requiredLevel`** (skill level from E2.M2) and **`branches`**. |
| **`branches`** | Stable **`branchId`**; **mutually exclusive** pick per tier; each branch lists **`perkIds`** unlocked when chosen. |
**`PerkDef` fields (prototype, embedded or referenced in catalog):**
| Field | Rule |
|--------|------|
| **`id`** | Lowercase `snake_case`, **immutable** after ship. |
| **`displayName`** | Player-facing; may change without migrating unlock state. |
| **`effectKind`** | Optional stub string for future systems (gather yield, craft efficiency, etc.) — **no** gameplay apply in E2.M3 core stories. |
### Designer note: tiers, branches, and level gates
- **Level gates** come from E2.M2 only: when a players **skill level** reaches **`requiredLevel`**, that tiers branches become **eligible** for selection (server-authoritative).
- **One branch per tier:** picking `scrap_efficiency` forecloses `bulk_haul` for that tier; perks on the chosen branch unlock; the other branchs perks for that tier do **not** unlock.
- **Other skills** (`refine`, `intrusion`) have **no** mastery rows in the prototype catalog until a deliberate expansion issue relaxes CI.
- **Respec** is not implemented in v1 — document in content if a perk is “permanent for the character.”
- **`displayName`** on a branch row is optional in schema; tier 1 and tier 2 may both include it for consistent authoring.
## NEO-46 handoff (server catalog load)
**Landed ([NEO-46](https://linear.app/neon-sprawl/issue/NEO-46)):** Server boot loads `content/mastery/*_mastery.json` fail-fast under `Game/Mastery/`, mirroring Python CI gates from [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries) plus cross-check against `ISkillDefinitionRegistry`. **`tierIndex`** uniqueness and sequential **1..N** per track are enforced at server boot and in **`validate_content.py`** (protects NEO-47 `PerkState` keys). Read-only **`IMasteryCatalogRegistry`** is registered for NEO-47+.
## NEO-47 handoff (perk unlock engine + PerkState persistence)
**Landed ([NEO-47](https://linear.app/neon-sprawl/issue/NEO-47)):** `IPlayerPerkStateStore` (in-memory + Postgres **`V004`**) persists branch picks and unlocked `perkId`s. **`PerkUnlockEngine`** evaluates tier-1 explicit branch picks and **path-auto** tier-2 unlocks from the tier-1 `branchId` when skill level meets gates (no second pick at tier 2 for prototype salvage). Level-up re-evaluation runs inside **`SkillProgressionGrantOperations`** after XP grants (HTTP POST, gather interact, mission/refine helpers). Stable deny **`reasonCode`** values for invalid picks. Internal **`PerkUnlockEvent`** returned from engine methods.
## NEO-48 handoff (perk state HTTP + Bruno)
**Landed ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)):** Versioned **`GET`/`POST /game/players/{id}/perk-state`** — read model + branch selection via **`PerkUnlockEngine`**; structured **`selected`** / **`reasonCode`** / **`unlockedEvents`** envelopes. Bruno: `bruno/neon-sprawl-server/perk-state/`; [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); plan [NEO-48](../../plans/NEO-48-implementation-plan.md); manual QA [`NEO-48.md`](../../manual-qa/NEO-48.md).
## NEO-49 handoff (perk_unlock telemetry hook sites)
**Landed ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)):** Comment-only **`perk_unlock`** hook site in **`PerkUnlockEngine.TryUnlockPerks`** (authoritative unlock anchor; covers branch-pick and level-up path-auto). **`TODO(E9.M1)`**; no production ingest. [server README — Perk unlock engine and telemetry hooks](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49); plan [NEO-49](../../plans/NEO-49-implementation-plan.md); manual QA [`NEO-49.md`](../../manual-qa/NEO-49.md).
## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))
The **first shipped mastery track** is **`salvage`** only:
| Tier | `requiredLevel` | Branches (pick one) | Tier-2 `perkId` (chosen branch only) |
|------|-----------------|---------------------|--------------------------------------|
| 1 | 2 | `scrap_efficiency` · `bulk_haul` | *(none — branch pick only)* |
| 2 | 4 | same branch ids | `salvage_scrap_efficiency_1` · `salvage_bulk_haul_1` |
**Rules:** Do **not** rename `branchId` / `perkId` values to “fix” wording—change `displayName` only, or add new ids and migrate content. Expanding mastery to **`refine`** / **`intrusion`** belongs in a new Linear issue once the catalog gate intentionally grows.
## Responsibilities
- 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 site in **`PerkUnlockEngine.TryUnlockPerks`** ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) — comments-only); E9.M1 catalog ingest deferred.
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 2.
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 2, E2.M3.
- Game design: [`docs/game-design/skills.md`](../../game-design/skills.md) — Mastery and perks.
- [Module dependency register](module_dependency_register.md)

View File

@ -7,7 +7,7 @@
| **Module ID** | E3.M3 |
| **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) |
| **Stage target** | Prototype |
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
| **Status** | In Progress — Slice 1 backlog [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)[NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) ([E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md); see [dependency register](module_dependency_register.md)) |
## Purpose
@ -33,10 +33,39 @@ Item definitions, rarity/quality, stackability, and equipment metadata plus `Inv
- **E3.M2**, **E3.M4**, **E5.M3**, **E7.M2**, **E8.M3**, and any system moving items.
## Designer note: stack, slot, and v1 scope
Each **`ItemDef`** row under `content/items/*.json` declares:
- **`stackMax`** — maximum units per inventory slot for that def (inventory engine in E3M3-05+ enforces this on add).
- **`inventorySlotKind`** — **`bag`** (general inventory) vs **`equipment`** (equip-stub slot only in prototype; full loadout rules are later).
- **`prototypeRole`** — Slice 1 archetype for CI coverage (`material` → `equip_stub`); not a player-facing bucket label (see [items.md](../../game-design/items.md) design lenses).
**Prototype v1:** optional schema fields **`rarity`**, **`bindPolicy`**, **`durabilityMax`** exist for forward compatibility but are **omitted** on all six shipped rows. **No durability mutation** or repair sinks in Slice 1—those belong to [E3.M4](E3_M4_SinkAndDurabilityLifecycle.md).
## Prototype Slice 1 freeze (NEO-50)
The **first shipped six-item spine** under `content/items/*.json` is **frozen** for downstream references (gather yields, recipes, inventory grants) until a deliberate migration or follow-up issue expands the roster.
| `ItemDef.id` | `prototypeRole` | `inventorySlotKind` | `stackMax` |
|--------------|-----------------|---------------------|------------|
| **`scrap_metal_bulk`** | `material` | `bag` | 999 |
| **`refined_plate_stock`** | `intermediate` | `bag` | 999 |
| **`field_stim_mk0`** | `consumable` | `bag` | 20 |
| **`survey_drone_kit`** | `utility` | `bag` | 1 |
| **`contract_handoff_token`** | `quest_token` | `bag` | 1 |
| **`prototype_armor_shell`** | `equip_stub` | `equipment` | 1 |
**Rules:** Do **not** rename these `id` values—change **`displayName`** only, or add a new `id` in a follow-up issue. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) enforces **exactly** these six ids, **one row per `prototypeRole`**, and duplicate-`id` rejection across all item JSON files. Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows.
## Related implementation slices
Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures.
**Server load (NEO-51):** On host startup, `server/NeonSprawl.Server/Game/Items/` loads `content/items/*_items.json` with the same validation gates as CI (`scripts/validate_content.py`) and **refuses to listen** when the catalog is invalid. Config and discovery: [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). Plan: [NEO-51 implementation plan](../../plans/NEO-51-implementation-plan.md).
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks).
## Source anchors
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 3.

View File

@ -51,8 +51,10 @@ 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 + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/``MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) |
| E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **Still planned:** `IItemDefinitionRegistry` (NEO-52+), inventory store, HTTP. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
---

View File

@ -29,12 +29,14 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| E2.M1 | SkillDefinitionRegistry | None | SkillDef, SkillCategory, UnlockRequirement, allowedXpSourceKinds | Prototype | In Progress |
| E2.M2 | XpAwardAndLevelEngine | E2.M1 | XpGrantEvent, LevelCurve, LevelUpEvent | Prototype | In Progress |
| E2.M3 | MasteryAndPerkUnlocks | E2.M2 | MasteryTrack, PerkUnlockEvent, PerkState | Pre-production | Planned |
| E2.M3 | MasteryAndPerkUnlocks | E2.M1, E2.M2 | MasteryTrack, PerkDef, PerkUnlockEvent, PerkState | Pre-production | In Progress |
| E2.M4 | ProgressionPacingControls | E2.M2, E9.M2 | XpModifierProfile, CatchUpRule, PacingPolicy | Pre-production | Planned |
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). **NEO-33** (content + CI + docs) moves the register row to **In Progress**; **NEO-34+** cover server registry and HTTP. Update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when slices land.
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
**E2.M2 note:** Epic 2 Slice 2 — [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) (progression snapshot **GET**) and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (grant **POST**, persistence, level-up payloads); label **`E2.M2`**. Module doc [E2_M2_XpAwardAndLevelEngine.md](E2_M2_XpAwardAndLevelEngine.md). Slice 3 integration: [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)[NEO-43](https://linear.app/neon-sprawl/issue/NEO-43) landed; **NEO-44** (gig XP, Epic 5) remains backlog. Keep aligned with [documentation and implementation alignment](documentation_and_implementation_alignment.md).
**E2.M3 note:** Epic 2 Slice 4 — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49); label **`E2.M3`**. **NEO-45 landed:** prototype **`salvage`** mastery catalog + schema + CI. **NEO-46 landed:** fail-fast server mastery catalog load. **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants. **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook in `PerkUnlockEngine.TryUnlockPerks`. **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** + Bruno `perk-state/`. Flagship track; see [E2_M3_MasteryAndPerkUnlocks.md](E2_M3_MasteryAndPerkUnlocks.md), [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md), and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E2.M3 row.
### Epic 3 — Crafting Economy
@ -42,10 +44,12 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|---|---|---|---|---|---|
| E3.M1 | ResourceNodeAndGatherLoop | E1.M3, E2.M2 | ResourceNodeDef, GatherResult, ResourceYieldTable | Prototype | In Progress |
| E3.M2 | RefinementAndRecipeExecution | E3.M1, E3.M3 | RecipeDef, CraftRequest, CraftResult | Prototype | Planned |
| E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | Planned |
| E3.M3 | ItemizationAndInventorySchema | None | ItemDef, ItemInstance, InventorySlot | Prototype | In Progress |
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
| E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned |
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI) and **NEO-51** (server fail-fast load) moved the register row to **In Progress**; later slices update the alignment table as they land.
### Epic 4 — World Topology
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |

View File

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

View File

@ -0,0 +1,69 @@
# Manual QA — NEO-48 (perk state GET + branch selection POST)
Reference: [implementation plan](../plans/NEO-48-implementation-plan.md), [NEO-47 plan](../plans/NEO-47-implementation-plan.md) (unlock engine).
## Preconditions
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`).
- Dev player `dev-local-1` exists in position state (default in-memory / seeded Postgres).
## GET empty state
```bash
curl -sS "http://localhost:5253/game/players/dev-local-1/perk-state" | jq .
```
- [ ] HTTP **200**; `schemaVersion` **1**; `branchPicks` **[]**; `unlockedPerkIds` **[]** (fresh server).
## POST tier-1 pick (happy path)
1. Grant salvage XP to reach level **2** (prototype curve: **100** total XP):
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/skill-progression" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","amount":100,"sourceKind":"activity"}' | jq .
```
2. Select tier-1 branch:
```bash
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}' | jq .
```
- [ ] `selected: true`; `perkState.branchPicks` contains salvage tier 1 `scrap_efficiency`; `unlockedEvents` empty at tier 1.
- [ ] Repeat **GET** — same pick visible.
## POST deny (mastery fixture reset)
Requires **`Game:EnableMasteryFixtureApi`** (on in Development) or Development environment.
```bash
# Reset dev-local-1 to salvage level 1, no perks
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/__dev/mastery-fixture" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"resetPerkState":true,"skillXp":{"salvage":0}}' | jq .
curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/perk-state" \
-H "Content-Type: application/json" \
-d '{"schemaVersion":1,"skillId":"salvage","tierIndex":1,"branchId":"scrap_efficiency"}' | jq .
# expect level_too_low
```
- [ ] Fixture reset + branch POST → `level_too_low`.
- [ ] Fixture reset with `skillXp.salvage: 100`, pick `scrap_efficiency`, POST `bulk_haul`**`branch_already_chosen`**.
## Bruno
- [ ] Run folder `bruno/neon-sprawl-server/perk-state/` (GET + POST happy + deny smoke).
- [ ] Run **`Post branch select deny branch already chosen`** (self-contained; pre-request grant + first pick).
## Regression
```bash
cd server && dotnet test NeonSprawl.sln
```
Expect all tests pass including `PerkStateApiTests`.

View File

@ -0,0 +1,24 @@
# Manual QA — NEO-49 (perk_unlock telemetry hook sites)
Reference: [implementation plan](../plans/NEO-49-implementation-plan.md), [NEO-47 implementation plan](../plans/NEO-47-implementation-plan.md) (unlock engine).
## Preconditions
- Run `NeonSprawl.Server` (`dotnet run` from `server/NeonSprawl.Server`; default `http://localhost:5253`).
- This story adds **comments only**; perk unlock behavior matches NEO-47.
## Code review check
- [ ] Open `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs` and locate **`TryUnlockPerks`**. Confirm a **comment block** marks the future E9.M1 event **`perk_unlock`** after `PerkUnlockEvent` instances are built.
- [ ] Confirm **`TODO(E9.M1)`** and planned payload fields: `playerId`, `perkId`, `skillId`, `tierIndex`, `branchId`, `source` (`BranchPick` | `LevelUp`).
- [ ] Confirm there are **no** new `ILogger` or metrics calls for this event.
- [ ] Open `PerkUnlockEvent.cs` — summary cross-references the hook anchor in `TryUnlockPerks`.
- [ ] Open [server README — Perk unlock engine and telemetry hooks](../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49) — NEO-49 hook placement documented.
## Regression (optional)
```bash
cd server && dotnet test NeonSprawl.sln --no-restore 2>/dev/null || dotnet test NeonSprawl.sln
```
Expect all tests pass (comments-only change).

View File

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

View File

@ -0,0 +1,209 @@
# E3.M3 — Prototype story backlog (ItemizationAndInventorySchema)
Working backlog for **Epic 3 — Slice 1** ([items and inventory MVP](../decomposition/epics/epic_03_crafting_economy.md#slice-1---items-and-inventory-mvp)). Decomposition and contracts: [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md).
**Labels (Linear):** every issue **`E3.M3`**; add **`server`** / **`Feature`** or **`Story`** as listed per issue.
**Precursor (do not re-scope):** gather **skill XP** on `resource_node` interact is **E3.M1** anchor only ([NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)) — no `GatherResult` / inventory yet. This module owns **`ItemDef`**, **`ItemInstance`**, **`InventorySlot`**, and server-authoritative player inventory.
**Prototype item spine (frozen in E3M3-01):** six rows covering **material → intermediate → consumable → utility → quest token → equip stub** for the gather→refine→craft loop ([items.md](../game-design/items.md), [gathering.md](../game-design/gathering.md), [crafting.md](../game-design/crafting.md)).
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
| Slug | Linear |
|------|--------|
| E3M3-01 | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) |
| E3M3-02 | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) |
| E3M3-03 | [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) |
| E3M3-04 | [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) |
| E3M3-05 | [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) |
| E3M3-06 | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) |
| E3M3-07 | [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) |
**Dependency graph in Linear:** E3M3-02 blocked by E3M3-01. E3M3-03 blocked by E3M3-02. E3M3-04 and E3M3-05 blocked by E3M3-03. E3M3-06 blocked by E3M3-05. E3M3-07 blocked by E3M3-05 (may parallel E3M3-06).
---
## Story order (recommended)
| Order | Slug | Depends on |
|-------|------|------------|
| 1 | **E3M3-01** | None (content spine) |
| 2 | **E3M3-02** | E3M3-01 |
| 3 | **E3M3-03** | E3M3-02 |
| 4 | **E3M3-04** | E3M3-03 |
| 5 | **E3M3-05** | E3M3-03 |
| 6 | **E3M3-06** | E3M3-05 |
| 7 | **E3M3-07** | E3M3-05 |
**Downstream (separate modules):** [E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) Slice 2 gather yields → inventory grants; [E3.M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) craft I/O; [NEO-42](https://linear.app/neon-sprawl/issue/NEO-42) refine XP hook invokes after craft success.
---
### E3M3-01 — Prototype ItemDef starter set + schemas + CI
**Goal:** Lock content shape and CI validation for a **frozen six-item** prototype catalog before any server load.
**In scope**
- `content/schemas/item-def.schema.json` (or equivalent single-catalog schema).
- `content/items/prototype_items.json` with stable ids: `scrap_metal_bulk`, `refined_plate_stock`, `field_stim_mk0`, `survey_drone_kit`, `contract_handoff_token`, `prototype_armor_shell` (names/display may change; **ids frozen**).
- `scripts/validate_content.py`: schema validation, duplicate `id`, required `prototypeRole` coverage (one row per archetype), exact six-id allowlist for prototype.
- Designer note in [E3_M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) + `content/README.md`: stack rules, slot kinds (bag vs equip stub), v1 scope (no durability mutation).
**Out of scope**
- Server loader, inventory store, HTTP, client HUD.
**Acceptance criteria**
- [x] PR gate validates item JSON against schema.
- [x] Exactly six prototype item ids; duplicate `id` fails CI.
- [x] Stable id list documented in module doc freeze box.
**Landed ([NEO-50](https://linear.app/neon-sprawl/issue/NEO-50)):** [`content/items/prototype_items.json`](../../content/items/prototype_items.json), [`item-def.schema.json`](../../content/schemas/item-def.schema.json), CI gates in [`validate_content.py`](../../scripts/validate_content.py); plan [NEO-50-implementation-plan.md](NEO-50-implementation-plan.md).
---
### E3M3-02 — Server item catalog load (fail-fast)
**Goal:** Disk → host: startup load of `content/items/*.json` with CI-parity validation.
**In scope**
- Loader + registry interface under `server/NeonSprawl.Server/Game/Items/` (path TBD in plan).
- Fail-fast on malformed files, duplicate ids, unknown enum values.
- Unit tests (AAA) for loader happy path and failure modes.
**Out of scope**
- Per-player inventory, HTTP.
**Acceptance criteria**
- [ ] Server refuses boot when item catalog invalid (mirror E2.M1 catalog behavior).
- [ ] Registry resolves `ItemDef` by `id`.
---
### E3M3-03 — Item definition registry + DI
**Goal:** Injectable `IItemDefinitionRegistry` consumed by inventory and future craft/gather paths.
**In scope**
- `ItemDefinitionRegistry` implementation + DI registration.
- Unit tests (AAA): lookup, unknown id, prototype row metadata.
**Out of scope**
- HTTP, persistence, stack mutation.
**Acceptance criteria**
- [ ] Host resolves registry from DI; unknown `itemId` distinguishable from valid ids in tests.
---
### E3M3-04 — GET world item-definitions
**Goal:** Versioned read-only catalog for client preview, Bruno, and future codegen — mirror [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36) skill-definitions pattern.
**In scope**
- `GET /game/world/item-definitions` + DTOs + API tests (AAA).
- Bruno folder `bruno/neon-sprawl-server/item-definitions/`.
- `server/README.md` section.
**Out of scope**
- Per-player inventory mutation.
**Acceptance criteria**
- [ ] GET returns all six prototype defs with schema version field.
- [ ] Bruno happy path documented.
---
### E3M3-05 — Player inventory store + stack/slot rules engine
**Goal:** Server-authoritative `ItemInstance` bags with stack limits and structured deny reasons; persistence (in-memory + Postgres + migration).
**In scope**
- `IPlayerInventoryStore`, migration `V00X__player_inventory` (number in plan).
- Inventory operations: add stack, remove stack, slot capacity, `inventory_full` / `invalid_item` / `insufficient_quantity` reason codes.
- Unit + integration tests (AAA); mirror NEO-8/NEO-38 persistence policy.
**Out of scope**
- HTTP, Bruno, client HUD.
- Craft/gather automatic grants (callers in E3.M1 / E3.M2).
**Acceptance criteria**
- [ ] Add/remove respects `ItemDef` stack max from catalog.
- [ ] Full bag returns stable `reasonCode` without partial silent loss.
- [ ] Postgres + in-memory parity tests.
---
### E3M3-06 — Player inventory HTTP + Bruno
**Goal:** Versioned **GET** snapshot and **POST** grant/consume (or add/remove) for manual QA and future client.
**In scope**
- `GET /game/players/{id}/inventory` and mutating POST (exact path/shape in implementation plan).
- Known-player gate via `IPositionStateStore` (NEO-37 pattern).
- Bruno `bruno/neon-sprawl-server/inventory/`.
- `server/README.md`; API tests (AAA).
**Out of scope**
- Godot HUD (optional follow-up).
- Gather node depletion ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)).
**Acceptance criteria**
- [ ] GET returns instances + slots for seeded player.
- [ ] POST add/remove matches engine rules from E3M3-05.
- [ ] Bruno exercises happy path + at least one deny (`inventory_full` or unknown item).
---
### E3M3-07 — `item_created` / transfer-failure telemetry hook sites
**Goal:** Comment-only hooks on inventory mutation success/deny for future E9.M1 catalog events **`item_created`** and transfer failures.
**In scope**
- Hook placement in inventory 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 3 Slice 1 telemetry vocabulary in [epic_03](../decomposition/epics/epic_03_crafting_economy.md).
---
## After this backlog
- Decompose **[E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)** Slice 2 (gather yields → inventory) once E3M3-05+ land.
- 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 (E3M3-06+).
## Related docs
- [E3_M3_ItemizationAndInventorySchema.md](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md)
- [epic_03_crafting_economy.md](../decomposition/epics/epic_03_crafting_economy.md)
- [items.md](../game-design/items.md)
- [E2_M1_SkillDefinitionRegistry.md](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md) (catalog pattern precedent)

View File

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

View File

@ -0,0 +1,135 @@
# NEO-46 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-46 |
| **Title** | E2.M3: Server loads mastery catalog at startup (fail-fast) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast |
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
| **Branch** | `NEO-46-mastery-catalog-startup-load` |
| **Blocked by** | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) (content + CI) — **Done** on `main` |
## Kickoff clarifications
| Topic | Question | Answer |
|--------|----------|--------|
| **Code location** | `Game/Skills/` vs `Game/Mastery/`? | **`Game/Mastery/`** — E2.M3 boundary; NEO-47+ (unlock engine, `PerkState`, HTTP) stays cohesive. |
| **`tierIndex` gate** | Enforce uniqueness + sequential 1..N at server boot? | **Yes** — stricter than CI today; protects NEO-47 `PerkState` keys. |
| **Validation strategy** | C# in-process vs subprocess? | **In-process C#** mirroring [`scripts/validate_content.py`](../../scripts/validate_content.py) (NEO-34 precedent). |
| **NEO-45 gate parity** | Which Python constants/rules? | Mirror `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, Slice 4 single-track gate, unknown `skillId`, duplicate `perkId`, branch-set equality, unreferenced `perks`, branch/perk reference integrity — see [NEO-45 plan](NEO-45-implementation-plan.md) and E2.M3 **NEO-46 handoff**. |
## Goal, scope, and out-of-scope
**Goal:** On host startup, load all `content/mastery/*_mastery.json` files validated in CI, build an in-memory mastery catalog, **cross-check every track `skillId` against `ISkillDefinitionRegistry`**, and **refuse to listen** if any file is missing, malformed, or violates prototype gates. Expose read-only **`IMasteryCatalogRegistry`** for NEO-47+.
**In scope (from Linear):**
- Fail-fast boot load for mastery catalog(s) under agreed content path.
- `IMasteryCatalogRegistry` resolves tracks by `skillId` and perk metadata by `perkId`.
- Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`.
- Unit tests (AAA) for loader happy path + duplicate id / unknown skill deny.
- Host-level test: valid repo catalog → `WebApplicationFactory` starts; invalid fixture → startup fails with actionable message.
**Out of scope (from Linear):**
- Per-player perk state, unlock engine, HTTP (NEO-4749).
- Client HUD.
- Applying `effectKind` gameplay modifiers.
## Acceptance criteria checklist
- [x] Fail-fast boot load for mastery catalog(s) under agreed content path.
- [x] `IMasteryCatalogRegistry` resolves tracks by `skillId` and enumerates perk definitions.
- [x] Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`.
- [x] Unit tests for loader happy path + duplicate id / unknown skill deny.
## Technical approach
1. **Configuration:** Extend [`ContentPathsOptions`](../../server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs) with `MasteryDirectory` and optional `MasteryCatalogSchemaPath`. When unset, discover `content/mastery` by walking ancestors of `AppContext.BaseDirectory` (same pattern as `content/skills`). Schema default: `{parent of mastery dir}/schemas/mastery-catalog.schema.json`.
2. **Path resolution:** Add `MasteryCatalogPathResolution` under `Game/Mastery/` (mirror `SkillCatalogPathResolution`).
3. **Loader (`MasteryCatalogLoader`):** Enumerate `*_mastery.json` (top-level only, sorted). Per file:
- Parse JSON; validate whole document against `mastery-catalog.schema.json` via **JsonSchema.Net** (already on server from NEO-34).
- Post-schema gates aligned with [`validate_content.py`](../../scripts/validate_content.py) `_validate_mastery_catalogs` + `_prototype_slice4_gate`:
- `perks` map key == `PerkDef.id`
- Global duplicate `perkId` across files
- Global duplicate branch `perkIds` reference (one reference site per perk id catalog-wide)
- Unknown `perkIds` references; unreferenced `perks` entries
- Unknown `skillId` (against skill ids passed in from `ISkillDefinitionRegistry` / loaded skill catalog)
- Per-tier: duplicate `branchId`; `requiredLevel` strictly increasing; tier *N* `branchId` set equals tier *N1*
- **Slice 4:** exactly one track across all files; sole track `skillId` == `salvage` (`PrototypeSlice4MasteryCatalogRules`, constant cross-referenced to Python `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`)
- **Server-only (kickoff):** per track, `tierIndex` values unique and exactly `{1..N}` with no gaps (schema allows gaps; prototype catalog is 1..2).
4. **Models:** Immutable POCOs — `MasteryCatalog`, `MasteryTrackRow`, `MasteryTierRow`, `MasteryBranchRow`, `PerkDefRow` — enough for registry lookups and logging.
5. **Registry:** `IMasteryCatalogRegistry` with `TryGetTrack(string? skillId, out MasteryTrackRow?)`, `TryGetPerk(string? perkId, out PerkDefRow?)`, `GetTracksInSkillIdOrder()`, `GetPerksInIdOrder()` (or equivalent; name flexible).
6. **DI:** `AddMasteryCatalog(IServiceCollection, IConfiguration)` registers `MasteryCatalog` + `IMasteryCatalogRegistry`; loader receives skill ids from `SkillDefinitionCatalog` (resolve skill catalog first in factory delegate). **Eager resolve** in `Program.cs` after skill catalog (same as NEO-34).
7. **Logging:** Information log with resolved mastery directory, file count, track count, perk count.
8. **Docs:** `server/README.md``Content:MasteryDirectory` / schema override; note server `tierIndex` gate. Optional one-line pointer in E2.M3 module if implementation touches handoff section (only if needed for accuracy).
### Gate constant parity (C# ↔ Python)
| Rule | Python | C# (planned) |
|------|--------|----------------|
| Slice 4 sole track skill | `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID` | `PrototypeSlice4MasteryCatalogRules.SalvageSkillId` |
| Slice 4 track count | `_prototype_slice4_gate` | `TryGetSlice4GateError(trackSkillIds)` |
Comment in C# constants: `// Keep in sync with scripts/validate_content.py`.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Mastery/ContentPathsMasteryExtensions.cs` | Optional: mastery path helpers if not folded into shared resolution type. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs` | Discover / resolve `content/mastery` and schema paths. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs` | File I/O, schema validation, post-schema gates, skill cross-check. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs` | Immutable load result (tracks by `skillId`, perks by id). |
| `server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs` | DTO for one track after validation. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs` | Tier row (`tierIndex`, `requiredLevel`, branches). |
| `server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs` | Branch row (`branchId`, `displayName`, `perkIds`). |
| `server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs` | Perk metadata row. |
| `server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs` | Slice 4 gate (single salvage track). |
| `server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs` | Read-only lookup contract for game code. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs` | Adapter over `MasteryCatalog`. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs` | `AddMasteryCatalog` DI registration. |
| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs` | Repo schema / fixture path discovery for tests. |
| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs` | Loader unit tests (happy + failure modes). |
| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs` | Registry lookup tests + prototype fixture parity. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `MasteryDirectory`, `MasteryCatalogSchemaPath`. |
| `server/NeonSprawl.Server/Program.cs` | Register mastery catalog; eager-resolve after skill catalog. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Set `Content:MasteryDirectory` (discover repo `content/mastery`) so host tests load real prototype catalog. |
| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Same mastery path override as in-memory factory (if present). |
| `server/README.md` | Document mastery content paths and fail-fast behavior. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: note NEO-46 server load when landed (during implementation). |
## Tests
| Test file | Coverage |
|-----------|----------|
| `MasteryCatalogLoaderTests.cs` | **Happy:** temp dir with copy of repo schema + minimal valid catalog matching prototype salvage shape → load succeeds, track/perk counts. **Deny:** malformed JSON; schema violation; unknown `skillId` (skill catalog stub with only `refine`); duplicate `perkId` across files; duplicate global `perkIds` reference; orphan `perks` entry; Slice 4 wrong track count / wrong `skillId`; **`tierIndex`** gap or duplicate. Assert `InvalidOperationException` messages include file path. |
| `MasteryCatalogRegistryTests.cs` | `TryGetTrack("salvage")`, `TryGetPerk("salvage_scrap_efficiency_1")`; unknown ids return false; ordered enumeration. |
| `MasteryCatalogLoaderTests.cs` (host) | `InMemoryWebApplicationFactory` + `/health` + DI `IMasteryCatalogRegistry` resolves salvage track from repo content. |
| `MasteryCatalogLoaderTests.cs` (host negative) | Invalid mastery directory override → host fails startup (mirror `SkillDefinitionCatalogLoaderTests` host case). |
All new `[Fact]` methods use AAA with `// Arrange` / `// Act` / `// Assert` labels per [csharp-style](../../.cursor/rules/csharp-style.md).
## Open questions / risks
- **C# vs Python drift:** Mitigate with shared constant names documented in both places; optional follow-up to add `tierIndex` gate to `validate_content.py` (not required for NEO-46).
- **Test discovery:** `dotnet test` from `server/` must find `content/mastery` via same ancestor walk as skills; verify in CI.
- **Load order:** Mastery loader must run after skill catalog is available in DI (factory dependency on `SkillDefinitionCatalog`).
None blocking after kickoff clarifications.
## Decisions
| Decision | Rationale |
|----------|-----------|
| `Game/Mastery/` folder | User-approved kickoff; separates E2.M3 from E2.M1 skill catalog. |
| `tierIndex` 1..N gate at server boot | User-approved kickoff; protects NEO-47 persistence keys. |
| In-process C# validation | NEO-34 precedent; no Python on server images. |
| Cross-check skill ids from loaded `SkillDefinitionCatalog` | Linear AC; single source of truth with E2.M1. |

View File

@ -0,0 +1,159 @@
# NEO-47 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-47 |
| **Title** | E2.M3: Perk unlock engine + PerkState persistence |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence |
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
| **Branch** | `NEO-47-perk-unlock-engine-perkstate-persistence` |
| **Blocked by** | [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) (catalog load — **Done** on `main`), [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (skill level source — **Done** on `main`) |
## Kickoff clarifications
| Topic | Question | Answer |
|--------|----------|--------|
| **Tier 2 branch behavior** | At level 4, does the player pick a branch again or do tier-2 perks unlock automatically from the tier-1 choice? | User did not understand the question at kickoff → **Agent recommendation (adopted for this story):** **auto-path** — tier 1 is the only explicit branch pick; when skill level reaches tier 2s `requiredLevel` (4 for salvage), perks on the **same `branchId`** as the tier-1 pick unlock with **no second pick**. Tier 2 branch rows stay in the catalog for metadata/perks; `TrySelectBranch` denies with `tier_branch_not_selectable` if all branches in that tier carry perks (see rule below). |
| **Postgres schema** | Normalized tables vs JSON blob vs defer Postgres? | User asked for recommendation → **Agent recommendation (adopted):** **normalized** tables + Flyway **`V004`**, mirroring NEO-38 (`player_mastery_branch_pick`, `player_unlocked_perk`). Linear AC requires in-memory + Postgres in this story. |
| **PerkUnlockEvent surfacing** | Return list vs DI sink vs both? | User asked for recommendation → **Agent recommendation (adopted):** **return `IReadOnlyList<PerkUnlockEvent>`** from engine methods; callers (grant hook, future HTTP in NEO-48) may ignore for now. NEO-49 can add an optional `IPerkUnlockEventSink` without changing persistence. |
| **Level-up hook site** | Inside `SkillProgressionGrantOperations` vs separate service? | **User:** run **inside `SkillProgressionGrantOperations`** when `levelUps` is non-empty (same path as XP write). Implementation: call a small **`PerkUnlockEngine`** instance method from there (logic stays testable in `Game/Mastery/`, not duplicated in HTTP). |
### Tier 1 vs tier 2 (plain language)
Prototype **salvage** mastery ([`prototype_salvage_mastery.json`](../../content/mastery/prototype_salvage_mastery.json)):
| Tier | Skill level gate | What happens |
|------|------------------|--------------|
| **1** | 2 | Player **chooses** `scrap_efficiency` **or** `bulk_haul`. **No perks** yet — only records the branch. |
| **2** | 4 | **One perk** unlocks on the path they already chose (e.g. `salvage_scrap_efficiency_1` if they picked `scrap_efficiency` at tier 1). **No second choice** between the two branches. |
So “mutually exclusive per tier” applies to **tier 1s pick**; tier 2 is “follow your path,” not a new fork.
**Catalog rule (engine):** A tier **requires an explicit branch pick** when **every** branch in that tier has an empty `perkIds` array. A tier **auto-unlocks from path** when branches have perks and the player already has a `branchId` pick on the highest prior tier that required a pick (for prototype: tier 1 → tier 2).
## Goal, scope, and out-of-scope
**Goal:** Server-authoritative **`PerkState`** (unlocked `perkId` set + `branchId` per `tierIndex` per track) with unlock evaluation driven by **skill level** from E2.M2. When level crosses a tier gate, eligible tiers become selectable or auto-unlock per rules above. **Re-evaluate** on skill level-up after XP grants. Emit internal **`PerkUnlockEvent`** when perks unlock.
**In scope (from Linear):**
- `IPlayerPerkStateStore` — in-memory + Postgres + migration (NEO-38 pattern).
- Unlock engine: catalog + skill level; `PerkUnlockEvent` on unlock.
- Mutually exclusive branch pick where applicable; stable **`reasonCode`** on invalid picks.
- Level-up path after `SkillProgressionGrantOperations` when `levelUps` non-empty.
- Unit/integration tests (AAA): eligibility, branch select, deny paths, level-up auto-unlock.
**Out of scope (from Linear):**
- HTTP read/write (**NEO-48**).
- Gameplay `effectKind` application; respec; gig perks.
## Acceptance criteria checklist
- [x] `IPlayerPerkStateStore` with in-memory + Postgres path (migration) mirroring skill progression pattern.
- [x] Unlock engine reads catalog + skill level; emits internal `PerkUnlockEvent` when perks unlock.
- [x] Branch pick per tier is **mutually exclusive** where a tier requires a pick; stable deny `reasonCode` on invalid picks.
- [x] Level-up path (after `SkillProgressionGrantOperations`) triggers re-evaluation for affected skill.
- [x] Unit/integration tests (AAA): eligibility, branch select, deny paths.
## Technical approach
1. **Domain model (`Game/Mastery/`):**
- **`PerkStateSnapshot`** — `branchPicks`: `skillId``tierIndex``branchId`; `unlockedPerkIds`: set of `perkId`.
- **`PerkUnlockEvent`** — `playerId`, `perkId`, `skillId`, `tierIndex`, `branchId?`, `source` (`branch_pick` | `level_up`).
- **`PerkUnlockEngine`** — pure orchestration: `TrySelectBranch(...)`, `ReevaluateAfterLevelUp(...)`, both return **`PerkBranchSelectOutcome`** / **`PerkReevaluationOutcome`** with `success`, `reasonCode?`, `events`, updated snapshot.
2. **Eligibility (skill level):** Resolve level via `IPlayerSkillProgressionStore.GetXpTotals` + `ISkillLevelCurve.LevelFromTotalXp` (same as GET skill progression). Tier *T* is **reachable** when `level >= tier.RequiredLevel`.
3. **`TrySelectBranch(playerId, skillId, tierIndex, branchId)`:**
- Deny: `unknown_track`, `unknown_tier`, `unknown_branch`, `level_too_low`, `branch_already_chosen`, `tier_branch_not_selectable` (tier auto-unlocks from path — all branches have perks).
- Accept: persist pick; unlock any `perkIds` on the chosen branch for that tier (tier 1: none); emit events.
- Player write gate: store returns false when player missing from position/progression pattern → `player_not_found` (align with skill XP store).
4. **`ReevaluateAfterLevelUp(playerId, skillId, newLevel)`:**
- For each tier on track where `requiredLevel <= newLevel` and tier is **path-auto** (has perks, prior pick exists): unlock perks for branch matching prior picks `branchId`; skip already-unlocked ids; persist + events.
- Idempotent: re-running does not duplicate perks or events in state.
5. **Persistence (`IPlayerPerkStateStore`):**
- `GetSnapshot(string playerId)`
- `bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)` — fails if pick exists or player missing.
- `bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)` — additive, idempotent per perk.
- **Postgres:** `V004__player_perk_state.sql``player_mastery_branch_pick (player_id, skill_id, tier_index, branch_id)` PK `(player_id, skill_id, tier_index)`; `player_unlocked_perk (player_id, perk_id)` PK `(player_id, perk_id)`; FK to `player_position`. Bootstrap: `PostgresPerkStateBootstrap` (mirror `PostgresSkillProgressionBootstrap`).
6. **DI:** `AddPerkStateStore(IServiceCollection, IConfiguration)` — Postgres when `ConnectionStrings:NeonSprawl` set, else in-memory. Register **`PerkUnlockEngine`** as singleton (depends on store, `IMasteryCatalogRegistry`, `IPlayerSkillProgressionStore`, `ISkillLevelCurve`).
7. **Level-up hook:** In **`SkillProgressionGrantOperations.TryApplyGrant`**, after successful XP apply, foreach entry in `levelUps` call `perkUnlockEngine.ReevaluateAfterLevelUp(playerId, levelUp.SkillId, levelUp.NewLevel)`. Inject `PerkUnlockEngine` via new overload or optional parameter on `TryApplyGrant` (prefer overload used by HTTP + interact to avoid static service locator). Events returned but not yet exposed on HTTP response (NEO-48).
8. **Reason codes (stable, `snake_case`):** Document in code constants on `PerkUnlockReasonCodes` (or engine type); mirror in tests.
| Code | When |
|------|------|
| `unknown_track` | No mastery track for `skillId` |
| `unknown_tier` | `tierIndex` not on track |
| `unknown_branch` | `branchId` not in tier |
| `level_too_low` | Skill level below `requiredLevel` |
| `branch_already_chosen` | Pick already stored for `(skillId, tierIndex)` |
| `tier_branch_not_selectable` | Tier auto-unlocks from path (no manual pick) |
| `player_not_found` | Store cannot write player |
9. **Tests:** Use repo `content/mastery` + level curve (`level 2` @ 100 XP, `level 4` @ 450 XP). Manipulate XP via `IPlayerSkillProgressionStore` or `TryApplyGrant` in integration tests.
## Files to add
| Path | Purpose |
|------|---------|
| `server/db/migrations/V004__player_perk_state.sql` | Flyway DDL for branch picks + unlocked perks. |
| `server/NeonSprawl.Server/Game/Mastery/PerkStateSnapshot.cs` | In-memory read model for one player. |
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs` | Internal unlock event DTO. |
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockReasonCodes.cs` | Stable deny reason constants. |
| `server/NeonSprawl.Server/Game/Mastery/PerkBranchSelectOutcome.cs` | Result of `TrySelectBranch`. |
| `server/NeonSprawl.Server/Game/Mastery/PerkReevaluationOutcome.cs` | Result of `ReevaluateAfterLevelUp`. |
| `server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs` | Persistence abstraction. |
| `server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs` | In-memory store (dev player seed pattern from skill store). |
| `server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs` | Postgres implementation. |
| `server/NeonSprawl.Server/Game/Mastery/PostgresPerkStateBootstrap.cs` | One-time DDL apply for V004. |
| `server/NeonSprawl.Server/Game/Mastery/PerkStateServiceCollectionExtensions.cs` | `AddPerkStateStore` + register `PerkUnlockEngine`. |
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs` | Catalog + level + store orchestration. |
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkUnlockEngineTests.cs` | Unit tests: tier-1 pick, denies, level-4 auto-unlock, idempotent reevaluate. |
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkStatePersistenceIntegrationTests.cs` | Postgres: branch pick + unlock survive new factory. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs` | Call `PerkUnlockEngine.ReevaluateAfterLevelUp` for each `levelUps` entry on successful grant (user kickoff). |
| `server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs` | Pass `PerkUnlockEngine` into `TryApplyGrant` from POST handler. |
| `server/NeonSprawl.Server/Game/Interaction/InteractionApi.cs` | Pass engine into gather XP grant path (NEO-41). |
| `server/NeonSprawl.Server/Program.cs` | `AddPerkStateStore(builder.Configuration)`. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Replace `IPlayerPerkStateStore` with in-memory implementation for isolated tests. |
| `server/NeonSprawl.Server/NeonSprawl.Server.csproj` | Copy `V004` SQL to output (if not already covered by existing migration copy pattern for V003). |
## Tests
| Test file | Coverage |
|-----------|----------|
| `PerkUnlockEngineTests.cs` | **AAA:** At salvage level 2, `TrySelectBranch` tier 1 `scrap_efficiency` succeeds; other branchs tier-2 perk not unlocked. **Deny:** `level_too_low` at level 1; `branch_already_chosen` on second pick; `unknown_branch`; `tier_branch_not_selectable` on tier 2 pick. **Level-up:** set XP to 450+, call `ReevaluateAfterLevelUp``salvage_scrap_efficiency_1` unlocked; `bulk_haul` perk not unlocked. **Idempotent:** second reevaluate adds no duplicate perks. |
| `PerkStatePersistenceIntegrationTests.cs` | **RequirePostgres:** pick branch + unlock via engine, new `PostgresWebApplicationFactory`, `GetSnapshot` shows pick + perk. |
| `SkillProgressionGrantOperations` (via existing grant API tests or one new test) | Grant salvage XP crossing level 4 with tier-1 pick already stored → perk unlocked without HTTP perk endpoint (hook integration smoke). |
All new `[Fact]` methods use `// Arrange` / `// Act` / `// Assert` per [csharp-style](../../.cursor/rules/csharp-style.md).
## Open questions / risks
- **Tier rule generalization:** “Empty `perkIds` on all branches ⇒ pick tier” must stay aligned with future multi-tier catalogs; add loader comment cross-ref to engine.
- **Grant hook + Postgres transactions:** Skill XP write and perk unlock are separate stores today (same as hotbar vs position); acceptable for prototype; document if cross-store atomicity becomes a requirement.
- **NEO-48 DTO shape:** Internal `PerkStateSnapshot` should map cleanly to versioned HTTP JSON without renaming fields.
None blocking after kickoff clarifications.
## Decisions
| Decision | Rationale |
|----------|-----------|
| Tier 2 = auto-path from tier-1 `branchId` | E2M3 backlog “chosen branch path”; user needed plain-language explanation — adopted agent recommendation. |
| Normalized Postgres tables (V004) | User asked for recommendation; matches NEO-38 and query-friendly perk checks for future gates. |
| Return event list from engine | User asked for recommendation; minimal surface for NEO-47; NEO-49 can add sink. |
| Hook inside `SkillProgressionGrantOperations` | Explicit user choice; delegate to `PerkUnlockEngine` for testability. |
| Code under `Game/Mastery/` | NEO-46 boundary; cohesive with catalog registry. |

View File

@ -0,0 +1,135 @@
# NEO-48 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-48 |
| **Title** | E2.M3: Perk state GET + branch selection POST + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-48/e2m3-perk-state-get-branch-selection-post-bruno |
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
| **Branch** | `NEO-48-perk-state-get-branch-selection-post-bruno` |
| **Blocked by** | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47) — **Done** on `main` (`PerkUnlockEngine`, `IPlayerPerkStateStore`, `PerkUnlockReasonCodes`). |
## Kickoff clarifications
| Topic | Question | Resolution |
|--------|----------|------------|
| **POST URL** | Same resource path as GET vs dedicated sub-path? | **User:** **`POST /game/players/{id}/perk-state`** (same as GET; mirrors skill-progression / hotbar-loadout). |
| **POST success body** | Include unlock events from `PerkUnlockEngine`? | **User:** **Yes**`selected: true` + full **`perkState`** snapshot + **`unlockedEvents[]`** (mirrors NEO-38 `levelUps` on grant). |
| **GET JSON shape** | Nested maps vs arrays? | **Agent (repo pattern):** serialize branch picks as a **`branchPicks`** array of `{ skillId, picks: [{ tierIndex, branchId }] }` (ordinal sort by `skillId`; picks sorted by `tierIndex`). **`unlockedPerkIds`** as a sorted string array. Internal `PerkStateSnapshot` mapping only — no rename of persisted fields. |
| **Deny envelope** | Field names for success/deny? | **Agent (NEO-38 mirror):** HTTP **200** with **`selected`** (`true`/`false`), optional **`reasonCode`** (stable codes from `PerkUnlockReasonCodes`), and authoritative **`perkState`** on both paths. **400** for missing/wrong `schemaVersion`; **404** when player absent from position state (same gate as skill-progression GET). |
| **Telemetry at HTTP** | Duplicate NEO-49 hook comments on POST? | **No** — [NEO-49 plan](NEO-49-implementation-plan.md): engine `TryUnlockPerks` is the authoritative hook; HTTP calls `TrySelectBranch` and surfaces returned `Events` only in JSON. |
| **Bruno deny setup** | Separate fixture players vs reset API? | **User (PR follow-up):** `POST …/__dev/mastery-fixture` sets `skillXp` + `resetPerkState`; Bruno pre-request calls it on `dev-local-1` before deny smokes (not separate seeded players). |
## Goal, scope, and out-of-scope
**Goal:** Versioned HTTP **GET** read model and **POST** branch selection for per-player perk state, delegating all rules to **`PerkUnlockEngine`** from NEO-47. Match NEO-37/NEO-38 versioning, player gate, and structured deny patterns.
**In scope (from Linear):**
- **`GET /game/players/{id}/perk-state`** — `schemaVersion` **1** JSON: branch picks + unlocked perk ids.
- **`POST /game/players/{id}/perk-state`** — commit one branch pick; structured success/deny; stable **`reasonCode`** values from engine.
- Bruno under **`bruno/neon-sprawl-server/perk-state/`** (GET + POST happy + deny).
- **`server/README.md`** section; API integration tests (AAA).
- **`docs/manual-qa/NEO-48.md`** during implementation (repo convention for HTTP stories).
**Out of scope (from Linear):**
- Godot client HUD.
- Applying perk `effectKind` in gather/craft.
- New telemetry hooks (NEO-49 complete).
- Respec; gig perks.
## Acceptance criteria checklist
- [x] `GET /game/players/{id}/perk-state` returns versioned JSON v1.
- [x] `POST` branch selection applies NEO-47 unlock rules; structured denies on invalid tier/branch.
- [x] Bruno collection `bruno/neon-sprawl-server/perk-state/` (GET + POST + deny).
- [x] `server/README.md` section for perk-state endpoints.
- [x] Unit/integration tests (AAA) for API happy/deny paths.
## Technical approach
1. **Routes (`Game/Mastery/PerkStateApi.cs`):**
- **`GET /game/players/{id}/perk-state`** — trim id; **404** if `!IPositionStateStore.TryGetPosition`; else **200** + `PerkStateSnapshotResponse` built from `IPlayerPerkStateStore.GetSnapshot`.
- **`POST /game/players/{id}/perk-state`** — body `PerkBranchSelectRequest` (`schemaVersion` **1**, `skillId`, `tierIndex`, `branchId`). **400** if body null or `schemaVersion != 1`. **404** same player gate. Call **`perkUnlockEngine.TrySelectBranch(trimmedId, skillId, tierIndex, branchId)`**; map outcome to `PerkBranchSelectResponse`.
2. **Snapshot builder:** `PerkStateApi.BuildSnapshot(playerId, IPlayerPerkStateStore)` maps `PerkStateSnapshot` → JSON DTO:
- **`branchPicks`:** one entry per skill with any picks; each **`picks`** row has `tierIndex` + `branchId`.
- **`unlockedPerkIds`:** sorted ordinal list from set.
- Preserve **`schemaVersion` 1**, **`playerId`**.
3. **POST success (`selected: true`):**
- **`perkState`:** same shape as GET body (without re-wrapping `playerId` if shared type — use shared `PerkStateSnapshotResponse` or embed identical fields).
- **`unlockedEvents`:** map each `PerkUnlockEvent``{ perkId, skillId, tierIndex, branchId, source }` where **`source`** is JSON string **`branch_pick`** | **`level_up`** (snake_case; maps `PerkUnlockSource.BranchPick` / `LevelUp`). Empty array when tier-1 pick unlocks no perks yet.
4. **POST deny (`selected: false`):**
- **`reasonCode`:** pass through engine `ReasonCode` (`unknown_track`, `unknown_tier`, `unknown_branch`, `level_too_low`, `branch_already_chosen`, `tier_branch_not_selectable`, `player_not_found`).
- **`perkState`:** authoritative snapshot from outcome (unchanged on deny except where engine returns updated snapshot — use `outcome.Snapshot` always).
- **`unlockedEvents`:** empty array on deny.
5. **Wire-up:** `app.MapPerkStateApi()` in **`Program.cs`** after mastery/perk DI is available (`PerkUnlockEngine` already registered in `AddPerkStateStore`). No new store or engine logic in this story.
6. **Bruno / README / manual QA:**
- Sequence: optional **`POST …/skill-progression`** grant to reach salvage level 2, then **POST perk-state** tier-1 pick, **GET** verifies pick; deny example: second pick or `level_too_low` without grant.
- Document paths, schema fields, reason codes, and link to this plan + **`docs/manual-qa/NEO-48.md`**.
7. **Module doc (implementation batch):** Update [E2_M3](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) NEO-47 handoff line to note HTTP landed; refresh [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E2.M3 row when complete.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Mastery/PerkStateDtos.cs` | `PerkStateSnapshotResponse`, `PerkBranchPickTrackJson`, `PerkBranchPickRowJson`, `PerkBranchSelectRequest`, `PerkBranchSelectResponse`, `PerkUnlockedEventJson`. |
| `server/NeonSprawl.Server/Game/Mastery/PerkStateApi.cs` | `MapPerkStateApi`, `BuildSnapshot`, GET/POST handlers. |
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureDtos.cs` | Dev fixture request/response (`resetPerkState`, `skillXp`). |
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureApi.cs` | `POST …/__dev/mastery-fixture` (gated). |
| `server/NeonSprawl.Server/Game/Mastery/MasteryFixtureOperations.cs` | Batch XP apply + perk reset with XP rollback on reset failure. |
| `server/NeonSprawl.Server.Tests/Game/Mastery/PerkStateApiTests.cs` | In-memory host: GET empty defaults; POST tier-1 happy after XP grant; deny `level_too_low` / `branch_already_chosen`; GET reflects POST; **404** / **400** gates. |
| `bruno/neon-sprawl-server/perk-state/folder.bru` | Collection folder metadata. |
| `bruno/neon-sprawl-server/perk-state/Get perk state.bru` | GET smoke + schema assertions. |
| `bruno/neon-sprawl-server/perk-state/Post branch select tier1.bru` | Happy path (docs: grant salvage XP first if needed). |
| `bruno/neon-sprawl-server/perk-state/Post branch select deny level too low.bru` | Deny without level gate. |
| `docs/manual-qa/NEO-48.md` | Curl/Bruno checklist for GET/POST/deny. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register **`MapPerkStateApi()`** and conditional **`MapMasteryFixtureApi()`**. |
| `server/NeonSprawl.Server/Game/Skills/IPlayerSkillProgressionStore.cs` | **`TrySetSkillXpTotal`** / **`TrySetSkillXpTotals`** for dev fixture. |
| `server/NeonSprawl.Server/Game/Mastery/IPlayerPerkStateStore.cs` | **`TryResetPerkState`** for dev fixture. |
| `server/NeonSprawl.Server/Game/Skills/InMemoryPlayerSkillProgressionStore.cs` | Fixture XP set implementations. |
| `server/NeonSprawl.Server/Game/Skills/PostgresPlayerSkillProgressionStore.cs` | Fixture XP set implementations. |
| `server/NeonSprawl.Server/Game/Mastery/InMemoryPlayerPerkStateStore.cs` | **`TryResetPerkState`**. |
| `server/NeonSprawl.Server/Game/Mastery/PostgresPlayerPerkStateStore.cs` | **`TryResetPerkState`**. |
| `server/README.md` | New **Perk state (NEO-48)** section: GET/POST paths, schema v1, `selected`/`reasonCode`/`unlockedEvents`, reason code table (link `PerkUnlockReasonCodes`), Bruno path, manual QA link. |
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Note NEO-48 HTTP landed (replace “HTTP is NEO-48” pending wording in NEO-47 handoff). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: NEO-48 perk-state HTTP. |
| `docs/decomposition/modules/module_dependency_register.md` | E2.M3 note: NEO-48 planned → landed when shipped. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `PerkStateApiTests.cs` | **AAA** integration via `InMemoryWebApplicationFactory`: **GET** returns v1 empty state for dev player; **POST** after `POST …/skill-progression` grant (100 XP → level 2) selects `scrap_efficiency` tier 1 → `selected: true`, pick in **GET**; **POST** deny `level_too_low` without grant; **POST** deny `branch_already_chosen` on second pick; **404** unknown player; **400** bad `schemaVersion`. Optional: **POST** at level 4+ includes non-empty **`unlockedEvents`** when tier-2 perk unlocks on same request (path-auto). |
| `MasteryFixtureApiTests.cs` | Fixture route disabled → **404**; **400** null body / bad schema / negative XP; **404** unknown player; reset after progressed state → **`level_too_low`** on next branch POST. |
| Existing `PerkUnlockEngineTests.cs` | No changes required — engine rules remain covered; API tests are thin HTTP mapping. |
Bruno + **`docs/manual-qa/NEO-48.md`** supplement automated coverage. Postgres persistence of picks is already covered by **`PerkStatePersistenceIntegrationTests`** (NEO-47); no new Postgres API test unless a regression gap appears during implementation.
## Open questions / risks
- **Field name `selected` vs `granted`:** Chosen to distinguish perk branch pick from XP grant; document clearly in README to avoid client confusion.
- **Large `unlockedEvents` batches:** Prototype catalog emits at most a handful per request; no pagination needed.
- **Concurrent POST picks:** Same as NEO-47 store semantics; no new transactional requirement for this story.
## Decisions
| Decision | Rationale |
|----------|-----------|
| POST on same path as GET | User choice; matches skill-progression / hotbar. |
| Include `unlockedEvents` on success | User choice; parallels `levelUps`; supports client/QA without parsing perk id diffs. |
| Branch picks as sorted arrays in JSON | Stable serialization; consistent with skill-progression “unordered by contract, sorted for bytes” pattern. |
| Reuse NEO-47 `reasonCode` strings verbatim | README + tests + Bruno share one vocabulary with engine. |

View File

@ -0,0 +1,97 @@
# NEO-49 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-49 |
| **Title** | E2.M3: perk_unlock telemetry hook sites |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-49/e2m3-perk-unlock-telemetry-hook-sites |
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
| **Branch** | `NEO-49-perk-unlock-telemetry-hook-sites` |
| **Blocked by** | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47) — **Done** on `main` (`PerkUnlockEngine`, `PerkUnlockEvent`). May land in parallel with [NEO-48](https://linear.app/neon-sprawl/issue/NEO-48) (perk HTTP). |
## Kickoff clarifications
| Topic | Question / note | Resolution |
|--------|-----------------|------------|
| **Hook runtime behavior** | Comments-only vs dev `ILogger` (same as NEO-40)? | **User:** **comments-only** — no runtime emit, no production logging. |
| **Hook anchor** | Single site in `TryUnlockPerks` vs duplicate markers at callers? | User asked for recommendation → **Agent recommendation (adopted):** **single anchor** in `PerkUnlockEngine.TryUnlockPerks` immediately after `PerkUnlockEvent` instances are built (covers branch-pick unlocks, path-auto on pick, and level-up reevaluation). **Do not** duplicate at `SkillProgressionGrantOperations` or future NEO-48 HTTP — those call the engine and already receive `Events`; E9.M1 wiring can subscribe at the engine or via a future `IPerkUnlockEventSink` (NEO-47 plan note). |
| **Branch-select without unlock** | Hook on successful `TrySelectBranch` when `Events` is empty? | **No** — catalog event is **`perk_unlock`** only when a perk id is newly persisted; tier-1 pick-without-perk is not an unlock event. |
| **Deny paths** | `perk_branch_select_denied` hooks? | **Out of scope** (Linear); only **`perk_unlock`**. |
## Goal, scope, and out-of-scope
**Goal:** Document and place **comment-only hook sites** on the server-authoritative perk unlock path for future E9.M1 catalog event **`perk_unlock`**. Align with Epic 2 Slice 4 telemetry vocabulary ([epic_02 — Slice 4](../decomposition/epics/epic_02_skills_and_progression.md)).
**In scope (from Linear + E2M3-05):**
- Hook location(s) in unlock / branch-select path documented in **code comments** with **`TODO(E9.M1)`**.
- **`server/README.md`** + [E2_M3 module](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) note hook placement.
- Manual QA checklist per repo convention.
**Out of scope (from Linear):**
- E9.M1 catalog, ingest, dashboards.
- `ILogger` / metrics / dev-only log lines (rejected at kickoff).
- Optional `IPerkUnlockEventSink` implementation (deferred; comments-only this story).
## Acceptance criteria checklist
- [x] Hook location(s) in unlock / branch-select path documented in code comments.
- [x] `TODO(E9.M1)` alignment; no production logging calls.
- [x] Module doc + server README note hook placement.
## Technical approach
1. **Authoritative unlock surface:** `PerkUnlockEngine.TryUnlockPerks` — the only place new perk ids are persisted and `PerkUnlockEvent` records are created. All paths funnel here:
- **`TrySelectBranch`** → explicit pick tier perks + `ApplyPathAutoTiersAtOrBelowLevel` (may emit zero events on tier-1-only pick).
- **`ReevaluateAfterLevelUp`** → path-auto tiers after skill level-up (called from `SkillProgressionGrantOperations` and any future callers).
2. **Hook site — `perk_unlock`:** After building the `events` list (one `PerkUnlockEvent` per newly unlocked `perkId`), add a **comment block** before `return true`:
- Name future E9.M1 event **`perk_unlock`**.
- **`TODO(E9.M1): catalog emit`** (match NEO-30 / NEO-40 style).
- Document payload fields from `PerkUnlockEvent`: `playerId`, `perkId`, `skillId`, `tierIndex`, `branchId`, `source` (`BranchPick` | `LevelUp`).
- Note: emit **once per unlocked perk** in the batch (idempotent reevaluation produces no events when already unlocked).
3. **Cross-references (minimal):**
- One-line pointer in `PerkUnlockEvent.cs` xml summary → hook site in `TryUnlockPerks`.
- **`server/README.md`:** new short subsection under mastery (after NEO-46 block) for perk unlock engine + **NEO-49 telemetry hook** anchor.
- **`E2_M3_MasteryAndPerkUnlocks.md`:** replace “telemetry in NEO-49” backlog wording with **NEO-49 landed** line when implementation completes.
- **`documentation_and_implementation_alignment.md`:** E2.M3 row cites NEO-49 hook sites.
4. **NEO-48 readiness:** When perk HTTP lands, it will call `TrySelectBranch` and surface `Events`; no extra hook comments required at the API layer if the engine anchor exists.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/manual-qa/NEO-49.md` | Manual QA: verify comment blocks + no behavior/API change. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs` | **NEO-49:** comment-only `perk_unlock` hook site in `TryUnlockPerks` when events are created. |
| `server/NeonSprawl.Server/Game/Mastery/PerkUnlockEvent.cs` | Cross-reference hook anchor in type summary (no behavior change). |
| `server/README.md` | Document perk unlock telemetry hook placement (NEO-49). |
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Implementation snapshot: NEO-49 telemetry hooks landed. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: NEO-49 hook sites (still no E9.M1 ingest). |
## Tests
| Path | Change |
|------|--------|
| **None required** | Comments-only; existing `PerkUnlockEngineTests` and grant integration tests already cover unlock paths. |
**Manual verification:** `docs/manual-qa/NEO-49.md` — open `PerkUnlockEngine.cs`, confirm hook cites **`perk_unlock`**, **`TODO(E9.M1)`**, payload fields, and no new log statements; run `dotnet test` on server solution for regression sanity.
## Decisions (post-review)
| Topic | Decision |
|--------|----------|
| **E9.M1 `source` field** | Path-auto unlocks triggered inside **`TrySelectBranch`** (via `ApplyPathAutoTiersAtOrBelowLevel` at current level) currently use **`PerkUnlockSource.LevelUp`**, not `BranchPick`. When wiring E9.M1 ingest, product/catalog owners should confirm whether retroactive path-auto at pick time stays **`LevelUp`** or gains a distinct catalog value; hook comments list `BranchPick \| LevelUp` as implemented today. |
## Open questions / risks
**None** for NEO-49 scope. NEO-47 blocker is complete; comments-only avoids production logging risk. If E9.M1 later needs caller context (HTTP request id), prefer `IPerkUnlockEventSink` at call sites without moving the primary hook documentation away from `TryUnlockPerks`.

View File

@ -0,0 +1,109 @@
# NEO-50 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-50 |
| **Title** | E3.M3: Lock prototype ItemDef starter set + schemas + CI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-50/e3m3-lock-prototype-itemdef-starter-set-schemas-ci |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 · backlog **E3M3-01** |
| **Branch** | `NEO-50-itemdef-starter-set-schemas-ci` |
## Kickoff clarifications
| Topic | Question | Answer |
|--------|----------|--------|
| **Prototype role field** | `prototypeRole` vs `designBucket` vs `tags` only? | **Agent recommendation (accepted):** `prototypeRole` enum — one value per frozen archetype (`material` \| `intermediate` \| `consumable` \| `utility` \| `quest_token` \| `equip_stub`). CI requires **exactly one row per role** (same pattern as skill category coverage). User asked for suggestion; design buckets in [items.md](../game-design/items.md) overlap and are not 1:1 with the six-id spine. |
| **Stack + slot metadata** | Explicit fields vs deferred? | **Agent recommendation (accepted):** **Explicit per row:** `stackMax` (int ≥ 1) and `inventorySlotKind` (`bag` \| `equipment`). Locks stack/slot rules in content before NEO-51 server load and E3M3-05 inventory engine. |
| **Forward-compat stubs** | Optional `rarity` / `bindPolicy` / `durabilityMax` on schema? | **Yes** — optional fields on `ItemDef`; omit on all six prototype rows (documented unused in v1). User confirmed. |
| **Catalog envelope** | File shape? | **Mirror skills:** `{ "schemaVersion": 1, "items": [ … ] }` in `content/items/prototype_items.json`; each row validates against `content/schemas/item-def.schema.json`. |
| **Base branch** | Branch from `main` after PR #84? | **Yes**`main` includes [E3M3-prototype-backlog](E3M3-prototype-backlog.md) and README freeze pointers. |
### Frozen prototype ids (Slice 1 — item spine)
| `id` | `prototypeRole` | `inventorySlotKind` | `stackMax` (initial) |
|------|-----------------|---------------------|----------------------|
| `scrap_metal_bulk` | `material` | `bag` | `999` |
| `refined_plate_stock` | `intermediate` | `bag` | `999` |
| `field_stim_mk0` | `consumable` | `bag` | `20` |
| `survey_drone_kit` | `utility` | `bag` | `1` |
| `contract_handoff_token` | `quest_token` | `bag` | `1` |
| `prototype_armor_shell` | `equip_stub` | `equipment` | `1` |
**Rules:** Do **not** rename `id` values after ship — change `displayName` only, or add a new `id` in a follow-up issue. Relaxing the six-id or six-role CI gate requires a new Linear issue.
## Goal, scope, and out-of-scope
**Goal:** Lock the **six-item** prototype catalog under `content/items/` with JSON Schema and CI validation (`scripts/validate_content.py`) so ids, stack rules, and slot metadata are stable before server load (NEO-51+).
**In scope (from Linear / [E3M3-prototype-backlog](E3M3-prototype-backlog.md#e3m3-01--prototype-itemdef-starter-set--schemas--ci)):**
- `content/schemas/item-def.schema.json` — single-row `ItemDef` contract.
- `content/items/prototype_items.json` — six frozen ids (table above).
- `scripts/validate_content.py` — schema validation, duplicate `id`, `prototypeRole` coverage, exact six-id allowlist.
- Designer note in **E3.M3** module doc + `content/README.md`: stack rules, bag vs equipment slot, v1 scope (no durability mutation).
**Out of scope (from Linear):**
- Server runtime load, inventory store, HTTP, client HUD (NEO-51+).
## Acceptance criteria checklist
- [x] PR gate / `validate_content.py` fails on invalid rows, schema mismatch, or duplicate item `id`.
- [x] Exactly **six** prototype item ids; wrong id set fails CI.
- [x] Each `prototypeRole` appears **exactly once** across the catalog.
- [x] Six prototype ids documented as **frozen** in E3.M3 module doc + `content/README.md`.
## Technical approach
1. **Row schema (`item-def.schema.json`):** Draft 2020-12 object; `additionalProperties: false`. Required: `id`, `displayName`, `prototypeRole`, `stackMax`, `inventorySlotKind`. Enums: `prototypeRole` (six values above), `inventorySlotKind` (`bag` \| `equipment`). `id` pattern `^[a-z][a-z0-9_]*$` (same as skills). Optional stubs: `rarity` (`common` \| `uncommon` \| `rare` \| `epic`), `bindPolicy` (`none` \| `bind_on_acquire` \| `bind_on_equip`), `durabilityMax` (integer ≥ 0) — all optional, unused in prototype rows.
2. **Catalog file:** `content/items/prototype_items.json` with `schemaVersion: 1` and six rows matching the freeze table; placeholder `displayName` strings acceptable.
3. **`validate_content.py`:** After existing skill / level-curve / mastery validation (or in parallel before final success):
- Discover `content/items/*_items.json`.
- Validate each row against `item-def.schema.json`.
- Track `seen_ids` across files; reject duplicates.
- **`_prototype_slice1_item_gate`:** ids must equal `PROTOTYPE_SLICE1_ITEM_IDS`; roles must cover each `prototypeRole` exactly once; `equip_stub` row must use `inventorySlotKind: equipment` and `stackMax: 1` (post-schema sanity).
4. **Docs:** E3.M3 — **Prototype Slice 1 freeze** subsection + designer note (bag vs equipment, stackMax, no durability mutation in v1). `content/README.md` — promote items path from “planned” to active with freeze list. CT.M1 — add item catalog paragraph. `documentation_and_implementation_alignment.md` — E3.M3 row → **In Progress** / NEO-50.
5. **No server/C#/client changes** in this story.
## Files to add
| Path | Purpose |
|------|---------|
| `content/schemas/item-def.schema.json` | JSON Schema for a single `ItemDef` row. |
| `content/items/prototype_items.json` | Prototype six-item catalog (`schemaVersion` + `items` array). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `scripts/validate_content.py` | Load/validate item catalogs; duplicate `id`; Slice 1 six-id + six-role gates; update success summary line. |
| `content/README.md` | Document `content/items/`, schema path, Slice 1 freeze (active, not “planned”). |
| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | Freeze table, designer note (stack/slot/v1 scope), link to plan. |
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph for item catalog validation. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 / NEO-50 status after catalog lands. |
## Tests
| Item | Coverage |
|------|----------|
| **CI / PR gate** | `.github/workflows/pr-gate.yml` already runs `python scripts/validate_content.py` — extended script is the regression signal. |
| **Manual negative cases** | From repo root after implementation: (1) duplicate `id` in catalog → non-zero exit; (2) remove one frozen id → gate error; (3) break schema (invalid `stackMax`) → schema error; (4) swap two `prototypeRole` values → role coverage error. |
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — reports item catalog + existing catalogs OK. |
No dedicated pytest module in-repo today (same as NEO-33 / NEO-45).
## Open questions / risks
- **Catalog growth:** Slice 1 gate requires **exactly** the six frozen ids; expanding the roster needs a follow-up issue to change `PROTOTYPE_SLICE1_ITEM_IDS` and role gate.
- **C# vs Python drift:** None until NEO-51; document “keep in sync” constant names in both places when server load lands.
- **None** otherwise at kickoff.
## Decisions
| Decision | Rationale |
|----------|-----------|
| `prototypeRole` enum + per-role CI | User requested agent recommendation; maps 1:1 to six-id vertical slice. |
| Explicit `stackMax` + `inventorySlotKind` | User requested agent recommendation; backlog requires stack/slot metadata before server load. |
| Optional `rarity` / `bindPolicy` / `durabilityMax` on schema | User chose **yes_stubs** for forward-compat without populating prototype rows. |

View File

@ -0,0 +1,101 @@
# NEO-51 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-51 |
| **Title** | E3.M3: Server loads item catalog at startup (fail-fast) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-51/e3m3-server-loads-item-catalog-at-startup-fail-fast |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-02) |
| **Branch** | `NEO-51-server-item-catalog-load-fail-fast` |
| **Precursor** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — frozen six-item catalog + CI (**Done** on `main`) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **NEO-51 vs NEO-52** | Ship `IItemDefinitionRegistry` + `ItemDefinitionRegistry` on this story, or only load + `ItemDefinitionCatalog`? | **Strict split** — NEO-51: loader + `ItemDefinitionCatalog` + eager fail-fast boot; [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) adds injectable `IItemDefinitionRegistry` (E3M3-03). Rationale: E3M3 backlog separates E3M3-02/03; skills combined registry in NEO-34 but items already have a dedicated registry ticket. | **User:** strict split (kickoff re-ask). |
| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror [NEO-34](NEO-34-implementation-plan.md) (`SkillDefinitionCatalogLoaderTests` happy path, failure modes, `Host_ShouldResolveCatalogFromDi`, `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`). | **User:** both loader + host boot tests (kickoff re-ask). |
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + Slice 1 item gate). Same rationale as NEO-34 (no Python on server images; shared constants commented against Python). | **Adopted** — NEO-34 precedent; no separate question needed. |
| **Options location** | New `ItemCatalogOptions` vs extend `ContentPathsOptions`? | **Extend** [`ContentPathsOptions`](../../server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs) with `ItemsDirectory` / `ItemDefSchemaPath` (skills + mastery already share `Content` section). | **Adopted** |
| **Lookup API on NEO-51** | How does “registry resolves `ItemDef` by `id`” land without NEO-52? | **`ItemDefinitionCatalog.ById`** (read-only map) + optional `TryGetItem(string id, out ItemDefRow row)` on the catalog type; no `IItemDefinitionRegistry` interface yet. | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** On host startup, load all `content/items/*_items.json` catalogs (validated in CI per [NEO-50](NEO-50-implementation-plan.md)), build an in-memory **`ItemDefinitionCatalog`**, **log** item count and resolved paths, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype Slice 1 gate. Mirror [E2.M1 / NEO-34](NEO-34-implementation-plan.md) skill-catalog behavior.
**In scope (from Linear + E3M3-02):**
- Loader + catalog under `server/NeonSprawl.Server/Game/Items/`.
- Configurable **items directory** and schema path (auto-discovery + overrides).
- Fail-fast semantics aligned with [`scripts/validate_content.py`](../../scripts/validate_content.py) item validation (`schemaVersion`, row schema, duplicate `id`, frozen six ids, `prototypeRole` coverage, equip_stub → `equipment` + `stackMax` 1).
- DI registration of **`ItemDefinitionCatalog`** singleton; eager resolve in `Program.cs` before `Run()`.
- Unit + host startup tests (AAA).
**Out of scope (from Linear):**
- Per-player inventory, HTTP ([NEO-53+](E3M3-prototype-backlog.md)).
- **`IItemDefinitionRegistry`** / `ItemDefinitionRegistry` (**NEO-52**).
## Acceptance criteria checklist
- [x] Server refuses boot when item catalog invalid (missing dir, no `*_items.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, Slice 1 gate failure).
- [x] `ItemDefinitionCatalog` resolves prototype rows by `id` (e.g. `scrap_metal_bulk`, `prototype_armor_shell`).
- [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory.
- [x] Success log includes **item count**, **catalog directory**, and **file count** (Information).
## Technical approach
1. **`Game/Items/` loader** — `ItemDefinitionCatalogLoader.Load(itemsDirectory, schemaPath, logger)`:
- Enumerate `*_items.json` (top-level only), ordered by path.
- Per file: parse JSON; require `schemaVersion == 1` and top-level `items` array.
- Per row: validate with `item-def.schema.json` via existing **JsonSchema.Net**; on clean rows, track `id`, `prototypeRole`, `inventorySlotKind`, `stackMax` for gates; build `ItemDefRow` dictionary.
- Reject duplicate `id` across files with paths in the error message.
- Run **`PrototypeSlice1ItemCatalogRules.TryGetSlice1GateError`** (mirror `PROTOTYPE_SLICE1_ITEM_IDS`, `PROTOTYPE_SLICE1_ITEM_ROLES`, equip_stub rules in `validate_content.py`).
- Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Item catalog validation failed:`.
2. **`ItemDefinitionCatalog`** — immutable snapshot: `ItemsDirectory`, `ById`, `DistinctItemCount`, `CatalogJsonFileCount`; `TryGetItem(id, out ItemDefRow?)` for lookups.
3. **`ItemCatalogPathResolution`** — `TryDiscoverItemsDirectory`, `ResolveItemsDirectory`, `ResolveItemDefSchemaPath` (parallel to skill path resolution: parent `schemas/item-def.schema.json` when schema path unset).
4. **`ItemCatalogServiceCollectionExtensions.AddItemDefinitionCatalog`** — bind `ContentPathsOptions`; register **`ItemDefinitionCatalog`** singleton factory (load on first resolve / same pattern as skills).
5. **`Program.cs`** — `AddItemDefinitionCatalog(configuration)`; after `Build()`, `GetRequiredService<ItemDefinitionCatalog>()` before routes/`Run()`.
6. **Tests** — temp-dir fixtures copying repo `item-def.schema.json`; valid six-item JSON matching [prototype_items.json](../../content/items/prototype_items.json); negative cases for each failure class; `InMemoryWebApplicationFactory` sets `Content:ItemsDirectory` to discovered repo path; host failure uses empty temp dir like skills test.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Items/ItemDefRow.cs` | Load-time row DTO (`Id`, `DisplayName`, `PrototypeRole`, `StackMax`, `InventorySlotKind`, optional forward-compat fields). |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs` | In-memory catalog + `ById` + `TryGetItem`. |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs` | Disk I/O, schema validation, Slice 1 gate, logging. |
| `server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
| `server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs` | Frozen six ids / six roles + equip_stub slot rules (sync comment to Python). |
| `server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs` | `AddItemDefinitionCatalog` DI registration. |
| `server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs` | Repo `content/items` + schema discovery for tests. |
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests (mirror `SkillDefinitionCatalogLoaderTests`). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ItemsDirectory`, `ItemDefSchemaPath` under existing `Content` section. |
| `server/NeonSprawl.Server/Program.cs` | Register item catalog; eager-resolve after build. |
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:ItemsDirectory` / `ItemDefSchemaPath` keys for override documentation. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:ItemsDirectory` to repo catalog so existing tests keep booting. |
| `server/README.md` | New **Item catalog (`content/items`, NEO-51)** section (config keys, discovery, fail-fast). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-51 server load in progress / landed when done. |
## Tests
| Test file | What it covers |
|-----------|------------------|
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs` | **Unit:** valid six-item prototype catalog loads; `items` not array; schema violation; duplicate `id` across files; wrong/missing Slice 1 ids; wrong `prototypeRole` set; equip_stub not `equipment` or `stackMax` ≠ 1; `schemaVersion` ≠ 1. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `ItemDefinitionCatalog` count 6; `WebApplicationFactory` with empty items dir fails startup with actionable message. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Schema path when only `ItemsDirectory` is set** | Resolve `{itemsDir}/../schemas/item-def.schema.json` (same rule as skills/mastery). Document in README + `ItemCatalogPathResolution`. | `adopted` |
| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:ItemsDirectory` explicitly (do not rely on discovery alone in CI). | `adopted` |
| **Constants drift vs Python** | Comment on `PrototypeSlice1ItemCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_SLICE1_ITEM_IDS` / `_prototype_slice1_item_gate`. | `adopted` |
None blocking beyond the above.

View File

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

View File

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

View File

@ -0,0 +1,59 @@
# Code review — NEO-46 mastery catalog startup load
**Date:** 2026-05-17
**Scope:** Branch `NEO-46-mastery-catalog-startup-load` · commits `898f935``174c390` vs `main`
**Base:** `main`
**Follow-up:** Addressed review suggestions (tests, `validate_content.py` `tierIndex` gate, E2.M3 module doc).
## Verdict
**Approve with nits**
## Summary
This change adds fail-fast loading of `content/mastery/*_mastery.json` at server startup, mirroring the NEO-34 skill-catalog pattern: path discovery, JsonSchema.Net validation, post-schema gates aligned with `scripts/validate_content.py`, a server-only `tierIndex` 1..N gate, cross-check against loaded skill ids, and a read-only `IMasteryCatalogRegistry` for NEO-47+. DI wires mastery load after `SkillDefinitionCatalog`; `Program.cs` eagerly resolves both catalogs before mapping routes. Tests cover the loader happy path, several deny paths, and host startup success/failure; all 13 mastery-related tests pass locally. Overall risk is low: read-only content load, no player state or HTTP surface yet.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-46-implementation-plan.md`](../plans/NEO-46-implementation-plan.md) | **Matches**`Game/Mastery/`, config keys, loader gates, registry API, eager resolve, README, alignment table, acceptance checklist marked complete. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — Status and NEO-46 handoff updated after follow-up. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row updated for NEO-46 landed. |
| [`docs/plans/NEO-45-implementation-plan.md`](../plans/NEO-45-implementation-plan.md) | **Matches** — Slice 4 constant/gate parity via `PrototypeSlice4MasteryCatalogRules`. |
| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches**`tierIndex` 1..N gate added in CI (follow-up). |
Register/tracking: E2.M3 alignment row is updated; module doc refreshed (follow-up).
## Blocking issues
None.
## Suggestions
1. ~~**Test coverage vs plan table** — The implementation plan lists deny cases for malformed JSON, schema violation, duplicate global `perkIds` reference, branch-set mismatch, non-increasing `requiredLevel`, Slice 4 wrong `skillId`, and duplicate `tierIndex`. Current tests cover unknown skill, duplicate perk across files, orphan perk, Slice 4 multi-track, `tierIndex` gap, and host negative startup; adding 23 focused loader tests (e.g. duplicate branch perk reference, `tierIndex` duplicate, wrong sole `skillId` for Slice 4) would close the gap and guard C#/Python drift.~~ **Done.** Added loader tests for malformed JSON, schema violation, duplicate perk reference, branch-set mismatch, flat `requiredLevel`, wrong Slice 4 sole `skillId`, and duplicate `tierIndex`.
2. ~~**Registry enumeration** — `GetTracksInSkillIdOrder()` / `GetPerksInIdOrder()` are part of the contract but untested; a small unit test on a constructed catalog would lock ordering for NEO-47 consumers.~~ **Done.** `MasteryCatalogRegistryTests` covers both enumeration methods.
3. ~~**E2.M3 module doc** — Update the **NEO-46 handoff** bullet to state that `tierIndex` uniqueness and sequential 1..N are enforced at server boot (not merely “consider”), and refresh the module **Status** line to mention NEO-46 (optional but keeps decomposition docs authoritative).~~ **Done.** `E2_M3_MasteryAndPerkUnlocks.md` updated.
4. ~~**CI follow-up (already in plan risks)** — Consider adding the `tierIndex` gate to `validate_content.py` so PR CI and server boot cannot diverge on catalog edits.~~ **Done.** `_tier_index_gate` in `validate_content.py` mirrors `MasteryCatalogLoader.TryGetTierIndexGateError`.
## Nits
- ~~Nit: `MasteryCatalogLoaderTests.CreateTempContentLayout()` leaves temp directories behind; consider `try/finally` with `Directory.Delete(..., recursive: true)` or `IDisposable` fixture (same pattern as other catalog tests if they exist).~~ **Done.** `TempContentLayout` implements `IDisposable`.
- ~~Nit: Host startup is exercised in both `MasteryCatalogLoaderTests` and `MasteryCatalogRegistryTests`; consolidating to one host integration test would reduce duplication (not required for merge).~~ **Done.** Host success path lives only in `MasteryCatalogRegistryTests`; loader tests keep host negative startup only.
- Nit: Bruno `mastery-catalog` folder only hits `/health` — appropriate for NEO-46 scope; NEO-48 will own real mastery HTTP Bruno coverage. **Deferred** — out of scope for NEO-46.
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~MasteryCatalog"
# Full server test suite (recommended before merge)
dotnet test NeonSprawl.sln
# Content gate (should stay green with repo catalog)
python3 scripts/validate_content.py
```
Manual: start server from clone with default content discovery; confirm Information log lists mastery directory, track count `1`, perk count `2` for prototype salvage catalog.

View File

@ -0,0 +1,76 @@
# Code review — NEO-47 perk unlock engine + PerkState persistence
**Date:** 2026-05-17
**Scope:** Branch `NEO-47-perk-unlock-engine-perkstate-persistence` · commits `e297b3a``0a2a6fe` vs `main`
**Base:** `main`
**Follow-up:** Addressed review suggestions in `0a2a6fe` (retroactive path-auto on branch pick, deny-code tests, unlock rollback, decomposition docs, gather-hook smoke, nits).
**Re-review (2026-05-17):** Verified follow-up commit against prior suggestions; no blocking regressions; **177** server tests pass.
## Verdict
**Approve**
## Summary
Server-authoritative perk state (`IPlayerPerkStateStore` in-memory + Postgres `V004`), `PerkUnlockEngine` (tier-1 picks, path-auto tier-2, retroactive path-auto after pick at current level), and level-up re-evaluation in `SkillProgressionGrantOperations` (HTTP POST, gather interact, mission/refine helpers). Follow-up commit adds `ApplyPathAutoTiersAtOrBelowLevel`, branch-pick rollback on perk write failure, `CanWritePlayer` / `TryRemoveBranchPick`, expanded deny tests, gather-hook smoke, and E2.M3 doc alignment. All **177** server tests pass locally. Ready for merge from a code perspective; perk HTTP remains **NEO-48**.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-47-implementation-plan.md`](../plans/NEO-47-implementation-plan.md) | **Matches** — store, engine, V004, hook site, reason codes, acceptance checklist marked complete; tier-1 pick / tier-2 auto-path behavior implemented as adopted kickoff decisions. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-47 handoff + Status updated after follow-up. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row notes NEO-47 landed after follow-up. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — unlock evaluation and persistence remain server-side; client unchanged until NEO-48. |
| [`docs/plans/NEO-46-implementation-plan.md`](../plans/NEO-46-implementation-plan.md) | **Matches** — engine consumes `IMasteryCatalogRegistry` and repo catalog; no loader regression in diff. |
Register/tracking: E2.M3 module doc and alignment row updated in follow-up (`0a2a6fe`).
## Blocking issues
None (initial review and re-review).
## Re-review (2026-05-17)
| Prior item | Status |
|------------|--------|
| Retroactive path-auto after branch pick | **Verified**`ApplyPathAutoTiersAtOrBelowLevel` called from `TrySelectBranch`; test `TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant`. |
| Deny-code tests (`unknown_track`, `unknown_tier`, `player_not_found`) | **Verified** — three new `[Fact]` methods in `PerkUnlockEngineTests.cs`. |
| Unlock rollback on store failure | **Verified**`TryRemoveBranchPick` + deny `player_not_found` when `TryUnlockPerks` fails after pick. |
| Decomposition docs | **Verified**`E2_M3_MasteryAndPerkUnlocks.md` NEO-47 handoff; alignment table row updated. |
| Gather-hook smoke | **Verified**`PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook` (440 + 10 XP gather). |
| Test scope / nits | **Verified** — singleton resolve from `factory.Services`; loader cross-ref on `TryGetTierIndexGateError`. |
**New issues from follow-up:** None blocking. Optional nit retained below (`unknown_track` for empty ids). Minor note: path-auto unlock events emitted from `TrySelectBranch` use `PerkUnlockSource.LevelUp` (same as reevaluate path) — acceptable for NEO-49 to refine if telemetry needs a distinct source for retroactive unlocks.
## Suggestions
1. ~~**Retroactive path-auto unlock after branch pick** — `TrySelectBranch` persists tier-1 picks but does not re-run path-auto evaluation. A player who reaches salvage **level 4+** before choosing tier 1 will not receive tier-2 perks until a **later** level-up, even though `requiredLevel` was already satisfied. NEO-48 branch POST will make this path easy to hit. After a successful pick, call `ReevaluateAfterLevelUp(playerId, skillId, currentLevel)` (level from `GetSkillLevel`) or extract shared “apply path-auto tiers ≤ level” logic. Add a test: XP to 450+, pick tier 1, assert tier-2 perk without another grant.~~ **Done.** `ApplyPathAutoTiersAtOrBelowLevel` shared by pick + level-up; test `TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant`.
2. ~~**Deny-code test gaps vs plan** — Plan table lists `unknown_track`, `unknown_tier`, and `player_not_found`. Tests cover `level_too_low`, `branch_already_chosen`, `unknown_branch`, and `tier_branch_not_selectable` but not the three above. Small engine unit tests (bad `skillId`, bad `tierIndex`, unknown player id on in-memory store without seed) would lock NEO-48 HTTP mapping.~~ **Done.** Added `TrySelectBranch_WhenUnknownTrack_*`, `WhenUnknownTier_*`, `WhenPlayerNotInStore_*`.
3. ~~**`UnlockPerks` store failure** — When `TryAddUnlockedPerks` returns `false`, the method returns an empty event list while `TrySelectBranch` may have already committed the branch pick. Harmless for prototype tier 1 (empty `perkIds`), but worth surfacing `player_not_found` on the outcome or rolling back the pick before NEO-48/expanded catalogs add perks on pick tiers.~~ **Done.** `TryUnlockPerks` returns false → `TryRemoveBranchPick` rollback + `player_not_found` deny.
4. ~~**Decomposition doc refresh** — Mirror NEO-46 follow-up: add NEO-47 handoff to `E2_M3_MasteryAndPerkUnlocks.md` (engine + stores, no HTTP) and update the E2.M3 row in `documentation_and_implementation_alignment.md`.~~ **Done.**
5. ~~**Gather-hook integration smoke** — Plan mentions `InteractionApi` grant path; implementation wires `perkUnlockEngine` correctly, but only `POST …/skill-progression` is smoke-tested for level-4 unlock. One interact test (or manual QA note in NEO-48) would close the loop for NEO-41 parity.~~ **Done.** `PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook`.
## Nits
- ~~Nit: `PerkUnlockEngineTests.CreateEngine` opens a `IServiceScope` without disposing; use `using var scope = …` or resolve from a single test scope.~~ **Done.** Resolve singletons from `factory.Services`.
- ~~Nit: `ReevaluateAfterLevelUp` calls `perkStore.GetSnapshot` inside the per-tier loop; a single snapshot per invocation is enough.~~ **Done.** `ApplyPathAutoTiersAtOrBelowLevel` refreshes snapshot only after successful unlock batches.
- Nit: Empty `skillId` / `branchId` on `TrySelectBranch` maps to `unknown_track` rather than a dedicated validation code — acceptable if NEO-48 trims inputs first.
- ~~Nit: Plan risk “loader comment cross-ref to engine tier rule” not added; optional comment on `MasteryCatalogLoader` or `TierRequiresExplicitPick` pointing to `PerkUnlockEngine`.~~ **Done.** Cross-ref on `TryGetTierIndexGateError` and `PerkUnlockEngine` class summary.
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkUnlock|FullyQualifiedName~PerkState"
# Full server suite (177 tests at re-review)
dotnet test NeonSprawl.sln
# Postgres integration (when ConnectionStrings__NeonSprawl is set)
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkStatePersistenceIntegration"
```
Manual: with Postgres, confirm `V004` tables exist after first perk write; grant salvage XP 100 → select tier-1 branch (future NEO-48) → grant to 450+ and confirm internal perk state via snapshot store or DB query until HTTP lands.

View File

@ -0,0 +1,67 @@
# Code review (re-review) — NEO-48 testing refactor + mastery fixture API
**Date:** 2026-05-17
**Scope:** Branch `NEO-48-perk-state-get-branch-selection-post-bruno` · commits `3ba8300``ffdbed2` (delta since [first review](2026-05-17-NEO-48.md)) · full branch vs `main` for merge readiness
**Base:** `main`
**Prior review:** [`2026-05-17-NEO-48.md`](2026-05-17-NEO-48.md) (perk-state HTTP + initial Bruno/tests)
**Follow-up:** R2 suggestions 13 and nits 1 + 3 + 4 addressed in `aba2644`; re-verified at **195** passing server tests.
## Verdict
**Approve**
## Summary
Follow-up work closes the first-review Bruno/test gaps and replaces a short-lived **fixture-player seeding** experiment with a cleaner **dev-only mastery fixture API**. **`POST /game/players/{id}/__dev/mastery-fixture`** (gated to Development, Testing, or `Game:EnableMasteryFixtureApi`) resets perk state and sets absolute skill XP without going through grant/level-up hooks—giving deterministic Bruno deny smokes on shared `dev-local-1`. **`PerkStateApiTests`** adds null-body **400** and HTTP deny smoke for `unknown_track` / `unknown_tier` / `unknown_branch`; **`MasteryFixtureApiTests`** verifies Production+flag-off returns **404** and that reset + XP zero yields **`level_too_low`** on the next branch POST. Bruno deny requests use `script:pre-request` + `axios` against the fixture endpoint; `{{playerId}}` from `Local.bru` is used consistently on deny flows. **191** server tests pass. Merge risk remains low; the new surface is explicitly non-production and tested for disable behavior.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-48-implementation-plan.md`](../plans/NEO-48-implementation-plan.md) | **Matches** — perk-state HTTP/Bruno acceptance unchanged; kickoff documents fixture API; **Files to add/modify** lists `MasteryFixture*` and store fixture methods. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-48 handoff covers HTTP only; dev fixture correctly omitted from module contracts. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — production perk rules still flow through `PerkUnlockEngine`; fixture bypasses grant path by design (dev/QA only). |
| [`docs/manual-qa/NEO-48.md`](../manual-qa/NEO-48.md) | **Matches** — fixture curl examples + Bruno deny checklist updated. |
| [`server/README.md`](../../server/README.md) | **Matches** — Perk state section documents fixture API, gating, and **404** when disabled. |
| Prior [`2026-05-17-NEO-48.md`](2026-05-17-NEO-48.md) | **Superseded for merge** by this re-review for the testing-refactor delta; first-review suggestions 13 marked done there remain valid. |
## Blocking issues
None.
## Suggestions
1. ~~**Atomic fixture apply** — `MasteryFixtureOperations.TryApply` can leave mixed state if `resetPerkState` succeeds and a later `skillXp` entry fails (or an earlier XP write succeeds before a later one fails). For a dev endpoint this is unlikely with Brunos fixed payloads, but consider applying XP first then reset, a single transaction on Postgres, or returning **409**/**500** with a note when partial apply is detected.~~ **Done.** Batch **`TrySetSkillXpTotals`** then **`resetPerkState`**; XP rollback via previous snapshot if reset fails; **400** for invalid `skillXp` before writes.
2. ~~**Fixture API test coverage** — Add small tests for **400** (null body / bad `schemaVersion`) and **404** (unknown player, negative XP in `skillXp`) on `__dev/mastery-fixture` to mirror perk-state gate tests.~~ **Done.** Four new tests in `MasteryFixtureApiTests`.
3. ~~**Document fixture vs grant semantics** — README could note that **`skillXp` on the fixture sets absolute totals without running `SkillProgressionGrantOperations` / perk level-up reevaluation** (intentional for deny setup; use normal grant API when testing unlock side effects).~~ **Done.** README Perk state section.
## Nits
- ~~Nit: `Get perk state.bru` still asserts `playerId === "dev-local-1"` while deny requests use `{{playerId}}` from the environment—harmless if `Local.bru` keeps the default; align assertions for copy-paste environments.~~ **Done.** GET uses `{{playerId}}` in URL and assertion.
- ~~Nit: `TryResetPerkState` / `TrySetSkillXpTotal` live on production store interfaces—acceptable for prototype; if the surface grows, consider a dev-only `IMasteryFixtureStore` facade later.~~ **Deferred.** Acceptable for E2.M3 prototype; revisit if more dev-only store methods accumulate.
- ~~Nit: Bruno deny flows mutate shared `dev-local-1`; folder `docs` explain fixture pre-requests—run deny requests before happy-path smokes or accept order sensitivity in manual runs.~~ **Done.** Folder `docs` note on run order.
- ~~Nit: `MasteryFixtureApiTests.PostMasteryFixture_ShouldResetSalvageXpAndPerkState_ThenLevelTooLowDenies` performs reset and deny POST both under **Act**; valid for an integration scenario, but heavy **Arrange** (grant + pick) could move to a private helper like `PerkStateApiTests.GrantSalvageLevel2Async`.~~ **Done.** `ArrangeSalvageProgressedWithTier1PickAsync`.
## Delta since first review (testing refactor)
| Area | Change |
|------|--------|
| **Bruno** | `Post branch select deny branch already chosen.bru`; `level_too_low` uses fixture pre-request (no skip-on-already-leveled); `{{playerId}}` + `baseUrl` resolution fixed. |
| **Server** | `MasteryFixtureApi` / `MasteryFixtureOperations` / DTOs; `Program.cs` conditional map; `EnableMasteryFixtureApi` in appsettings; store `TryResetPerkState` + `TrySetSkillXpTotal` (in-memory + Postgres). |
| **Tests** | `MasteryFixtureApiTests` (2); `PerkStateApiTests` +4 deny/null-body; `InMemoryWebApplicationFactory` enables fixture flag for Testing. |
| **Reverted approach** | `GamePlayerSeed` / extra fixture player ids (commit `b72201b`) removed in favor of fixture API (`ffdbed2`)—simpler ops story. |
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkState|MasteryFixture"
# Full server suite (195 tests after aba2644)
dotnet test NeonSprawl.sln
# Bruno (Development server, EnableMasteryFixtureApi on)
# bruno/neon-sprawl-server/perk-state/
```
Confirm deny Bruno requests pass on a fresh dev server; confirm `POST …/__dev/mastery-fixture` returns **404** when `ASPNETCORE_ENVIRONMENT=Production` and `Game:EnableMasteryFixtureApi=false`.

View File

@ -0,0 +1,59 @@
# Code review — NEO-48 perk state GET + branch selection POST
**Date:** 2026-05-17
**Scope:** Branch `NEO-48-perk-state-get-branch-selection-post-bruno` · commits `5290044``39c54c3` vs `main`
**Base:** `main`
**Follow-up:** Suggestions 13 and nit 1 addressed on branch; r2 follow-up (`aba2644`) closed fixture/testing items — see [`2026-05-17-NEO-48-r2.md`](2026-05-17-NEO-48-r2.md).
## Verdict
**Approve**
## Summary
NEO-48 adds thin HTTP mapping for versioned **`GET`/`POST /game/players/{id}/perk-state`**: read model (`branchPicks`, `unlockedPerkIds`), branch selection via **`PerkUnlockEngine.TrySelectBranch`**, and structured **`selected`** / **`reasonCode`** / **`perkState`** / **`unlockedEvents`** envelopes mirroring NEO-38 grant patterns. Eight integration tests cover 404/400 gates, happy tier-1 pick after XP grant, `level_too_low` / `branch_already_chosen` denies, and path-auto **`unlockedEvents`** at level 4+. Bruno folder, README, manual QA, and E2.M3 decomposition rows align with the implementation plan. **185** server tests pass locally. Low merge risk; no engine or persistence logic duplicated in the API layer.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-48-implementation-plan.md`](../plans/NEO-48-implementation-plan.md) | **Matches** — same-path GET/POST, schema v1, deny envelope, `unlockedEvents` on success, position-state 404 gate, Bruno/README/tests/manual QA; acceptance checklist complete. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-48 handoff section; HTTP responsibility listed under module responsibilities. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row cites NEO-48 landed + README/Bruno links. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E2.M3 note includes NEO-48 perk-state HTTP. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — branch picks and unlock evaluation remain server-authoritative; no client changes (out of scope). |
| [`docs/plans/NEO-47-implementation-plan.md`](../plans/NEO-47-implementation-plan.md) | **Matches** — API delegates to engine/store; no duplicate unlock rules; telemetry stays in `TryUnlockPerks` (NEO-49). |
Register/tracking: E2.M3 module doc, alignment row, and register note updated in implementation commit.
## Blocking issues
None.
## Suggestions
1. ~~**Bruno deny for `branch_already_chosen`** — Plan and manual QA describe a second POST to the same tier with a different branch; automated tests cover it (`PostPerkState_ShouldDenyBranchAlreadyChosen_OnSecondPick`), but Bruno only ships `Post branch select deny level too low.bru`. A fourth request (grant + first pick + conflicting second pick) would make the collection self-contained for CI/manual runs without relying on manual QA alone.~~ **Done.** Added `Post branch select deny branch already chosen.bru` with `script:pre-request` grant + first pick via `bru.sendRequest`.
2. ~~**Optional thin HTTP deny smoke** — Engine tests lock `unknown_track`, `unknown_tier`, and `unknown_branch`; one API test each (bad `skillId`, bad `tierIndex`, bad `branchId` on dev player after level gate) would guard JSON field mapping if DTOs change. Non-blocking given `PerkUnlockEngineTests` coverage.~~ **Done.** `PostPerkState_ShouldDenyUnknownTrack_*`, `UnknownTier_*`, `UnknownBranch_*` in `PerkStateApiTests`.
3. ~~**POST null body** — Handlers return **400** for `body is null` (same as skill-progression). Neither API suite asserts empty-body **400** today; add a one-liner in `PerkStateApiTests` if you want parity documentation in tests.~~ **Done.** `PostPerkState_ShouldReturnBadRequest_WhenBodyNull`.
## Nits
- ~~Nit: `Post branch select deny level too low.bru` intentionally passes when salvage is already leveled (restart note) — acceptable for shared dev player; document in Bruno folder `docs` if operators hit false greens often.~~ **Done.** Folder `docs` block in `perk-state/folder.bru`.
- ~~Nit: Path-auto tier-2 unlocks surfaced in **`unlockedEvents`** use **`source: level_up`** (not `branch_pick`) — consistent with NEO-47/NEO-49 notes; clients/telemetry should treat `source` as unlock channel, not HTTP verb.~~ **Deferred.** Documented in NEO-47/NEO-49; no API change required for NEO-48.
- ~~Nit: `PostPerkState_ShouldSelectTier1_AndGetReflectsPick_AfterSalvageLevel2` issues a follow-up **GET** inside **Assert** for end-to-end verification — valid AAA (verification in Assert); grant status checks in **Arrange** match other API tests in the repo.~~ **Deferred.** Accepted integration-test pattern; no change.
## Verification
```bash
# From repo root
dotnet test NeonSprawl.sln --filter "FullyQualifiedName~PerkStateApi"
# Full server suite (195 tests at final verification)
dotnet test NeonSprawl.sln
# Manual / Bruno
# docs/manual-qa/NEO-48.md
# bruno/neon-sprawl-server/perk-state/
```
Confirm **GET** empty state, **POST** tier-1 happy path after salvage grant (100 XP), **POST** deny codes, and **GET** reflects persisted pick; optional path-auto **`unlockedEvents`** at level 4+ (500 total XP before pick).

View File

@ -0,0 +1,54 @@
# Code review — NEO-49 perk_unlock telemetry hook sites
**Date:** 2026-05-17
**Scope:** Branch `NEO-49-perk-unlock-telemetry-hook-sites` · commits `f3e48f4``38ef54c` vs `main` (includes unrelated `0d63965` chore: `.vscodecsdt/` gitignore)
**Base:** `main`
**Follow-up:** Suggestions and actionable nits below are **done** (strikethrough + **Done.**).
## Verdict
**Approve with nits**
## Summary
NEO-49 adds a **comments-only** E9.M1 **`perk_unlock`** hook anchor in `PerkUnlockEngine.TryUnlockPerks` immediately after `PerkUnlockEvent` instances are built and perks are persisted—matching the adopted single-anchor plan and Epic 2 Slice 4 telemetry vocabulary. Documentation updates (implementation plan, manual QA, `server/README.md`, E2.M3 module, alignment row) are consistent. No runtime, API, or test changes; **177** server tests pass. Low merge risk; E9.M1 ingest remains deferred.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-49-implementation-plan.md`](../plans/NEO-49-implementation-plan.md) | **Matches** — single anchor in `TryUnlockPerks`, comments-only, `TODO(E9.M1)`, README + module + manual QA; acceptance checklist complete. |
| [`docs/plans/NEO-47-implementation-plan.md`](../plans/NEO-47-implementation-plan.md) | **Matches** — hook does not alter engine contracts; unlock funnel unchanged; NEO-47 note on future `IPerkUnlockEventSink` still valid. |
| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — NEO-49 handoff section; telemetry bullet updated. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row cites NEO-49 hook + manual QA. |
| [`docs/decomposition/epics/epic_02_skills_and_progression.md`](../decomposition/epics/epic_02_skills_and_progression.md) | **Matches** — Slice 4 lists `perk_unlock` telemetry hook; implementation is comment-only until E9.M1. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no client or authority boundary change. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E2.M3 note refreshed (NEO-45 → NEO-49 landed; NEO-48 still planned). |
Register/tracking: alignment table and E2.M3 module updated; register footnote not touched in this branch.
## Blocking issues
None.
## Suggestions
1. ~~**Refresh `module_dependency_register` E2.M3 note** — The footnote still ends with “**NEO-47** blocked by NEO-46…” while NEO-46/47/49 are landed on this branchs doc trail. Mirror the E2.M3 row in `documentation_and_implementation_alignment.md` (NEO-46 → NEO-47 → NEO-49) so the register does not contradict merge-ready `main` + this PR.~~ **Done.** `module_dependency_register.md` E2.M3 note updated.
2. ~~**E9.M1 `source` semantics** — NEO-47 re-review noted path-auto unlocks triggered from `TrySelectBranch` may carry `PerkUnlockSource.LevelUp`. The hook comment already lists `BranchPick | LevelUp`; when wiring ingest, document whether retroactive path-auto from branch pick should remain `LevelUp` or gain a distinct catalog value (product/E9.M1 decision, not blocking NEO-49).~~ **Done.** Documented in [NEO-49 plan](../plans/NEO-49-implementation-plan.md) **Decisions (post-review)**.
## Nits
- ~~Nit: `PerkUnlockEvent.cs` XML summary sits on **`PerkUnlockSource`** enum, not the **`PerkUnlockEvent`** record—manual QA says “Open `PerkUnlockEvent.cs`”; moving the summary to the record (or duplicating a one-liner on the record) would match reader intent.~~ **Done.** Hook-site summary on `PerkUnlockEvent` record; enum has its own one-line summary.
- Nit: Branch includes chore commit `0d63965` (`.vscodecsdt/` gitignore)—harmless; already isolated if reviewers prefer a cherry-pick-only view of NEO-49 commits.
## Verification
```bash
# From repo root — regression (177 tests at review)
dotnet test NeonSprawl.sln
# Manual QA checklist
# docs/manual-qa/NEO-49.md
```
Confirm `PerkUnlockEngine.TryUnlockPerks` hook block cites **`perk_unlock`**, **`TODO(E9.M1)`**, payload fields, and no new `ILogger`/metrics calls.

View File

@ -0,0 +1,58 @@
# Code review — NEO-50 prototype ItemDef catalog + CI
**Date:** 2026-05-17
**Scope:** Branch `NEO-50-itemdef-starter-set-schemas-ci` · commits `6616bfb``bbc6b20` · full branch vs `main`
**Base:** `main`
## Verdict
**Approve**
**Follow-up:** Review suggestions below are **done** (strikethrough + **Done.**).
## Summary
NEO-50 delivers a **content-only** Slice 1 spine: `item-def.schema.json`, six-row `prototype_items.json`, and extended `validate_content.py` with per-row schema validation, cross-file duplicate `id` detection, frozen six-id allowlist, one-row-per-`prototypeRole` coverage, and post-schema checks that `prototype_armor_shell` uses `equipment` + `stackMax: 1` when it carries `equip_stub`. Documentation (E3.M3 freeze table, `content/README.md`, CT.M1 CI paragraph, alignment register, implementation plan) matches kickoff decisions (`prototypeRole`, explicit `stackMax` / `inventorySlotKind`, optional forward-compat stubs). No server/C#/client surface — risk is low; PR gate already runs the script. One **should fix** tightens the equip-stub slot gate so a role/id swap cannot bypass equipment rules; backlog wording still mentions bucket/tags.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-50-implementation-plan.md`](../plans/NEO-50-implementation-plan.md) | **Matches** — acceptance checklist, freeze table, schema fields, and validation gates implemented as specified. |
| [`docs/plans/E3M3-prototype-backlog.md`](../plans/E3M3-prototype-backlog.md) | **Partially matches** — E3M3-01 scope and ids align; line 51 still says “required bucket/tag fields” (kickoff chose `prototypeRole` instead); acceptance checkboxes not ticked in backlog (plan checklist is ticked). |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — designer note, freeze table, CI pointers, v1 scope (no durability mutation). |
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — item catalog paragraph added; module **Status** in summary table still **Planned** (CT.M1 row not promoted — optional follow-up). |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row → In Progress / NEO-50 content landed (appropriate on branch pre-merge). |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 → **In Progress** with NEO-50 note. |
| [`content/README.md`](../../content/README.md) | **Matches** — items path active with six-id freeze pointer. |
## Blocking issues
None.
## Suggestions
1. ~~**Equip-stub slot gate by role, not id** — `_prototype_slice1_item_gate` only enforces `inventorySlotKind: equipment` and `stackMax: 1` when `prototype_armor_shell` has `prototypeRole: equip_stub`. Swapping `prototypeRole` between frozen ids (e.g. `survey_drone_kit` → `equip_stub` with `bag`) would pass CI while violating the designer contract. Resolve the row where `id_to_role[*] == "equip_stub"` (there is exactly one) and assert slot/stack on that id.~~ **Done.** Gate now resolves the row with `prototypeRole == "equip_stub"` and asserts `equipment` + `stackMax: 1` on that id (`scripts/validate_content.py`).
2. ~~**Backlog doc sync** — Update [`E3M3-prototype-backlog.md`](../plans/E3M3-prototype-backlog.md) E3M3-01 bullet from “bucket/tag fields” to `prototypeRole` (+ optional tick acceptance criteria) so backlog matches the implementation plan and shipped validator.~~ **Done.** E3M3-01 in-scope text, ticked acceptance criteria, and NEO-50 landed note.
## Nits
- Nit: Add a `// Keep in sync with scripts/validate_content.py` comment placeholder in NEO-51 server loader constants when C# load lands (plan risk table already calls this out).
- Nit: `CT_M1` module summary **Status** remains **Planned** while skill/mastery/item CI is live — consider **In Progress** when promoting CT.M1 is intentional scope.
## Verification
```bash
# From repo root
pip install -r scripts/requirements-content.txt
python3 scripts/validate_content.py
python3 scripts/check_decomposition_language.py
# Manual negatives (expect non-zero exit + stderr message)
# 1. Duplicate id in prototype_items.json
# 2. Remove one frozen id
# 3. Invalid stackMax (0) on a row
# 4. Duplicate prototypeRole on two rows
```
Confirm `.github/workflows/pr-gate.yml` passes on the PR (content validation step unchanged except script behavior).

View File

@ -0,0 +1,60 @@
# Code review — NEO-51 item catalog fail-fast load
**Date:** 2026-05-17
**Scope:** Branch `NEO-51-server-item-catalog-load-fail-fast` (`c2366ee` … `b5674c4` vs `main`)
**Base:** `main` (post NEO-50 merge)
## Verdict
**Approve with nits**
**Follow-up:** Suggestions 13 and the temp-dir nit below are **done** (strikethrough + **Done.**).
## Summary
NEO-51 adds server-side fail-fast loading of `content/items/*_items.json` at startup, mirroring the NEO-34 skill-catalog pattern: JsonSchema.Net row validation, duplicate-`id` rejection, `schemaVersion` 1 enforcement, and a C# prototype Slice 1 gate aligned with `scripts/validate_content.py`. `ItemDefinitionCatalog` is registered as a singleton and eagerly resolved in `Program.cs` before the host listens; tests cover loader happy/failure paths and host boot success/failure. The change is localized, well-tested for the main failure classes, and documentation/plan alignment is strong. Residual risk is low: constants drift vs Python (mitigated by comments) and two plan-listed negative tests not yet written.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-51-implementation-plan.md`](../plans/NEO-51-implementation-plan.md) | **Matches** — loader, path resolution, DI, eager boot, README, alignment table, strict NEO-51/NEO-52 split, in-process validation, `ContentPathsOptions` extension. Acceptance checklist marked complete. |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — frozen six-item roster, roles, slot kinds, and stack rules enforced at load; no inventory/HTTP (out of scope). Module **Status** line still generic “In Progress”; server load not called out on that page (see suggestion). |
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — server load duplicates CI item rules (schema, duplicates, Slice 1 gate). |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row updated with NEO-51 landed + README link. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E3.M3 still **In Progress** (correct); footnote still says “NEO-50+ moves row” without naming NEO-51 server load (optional doc tweak). |
## Blocking issues
None.
## Suggestions
1. ~~**Test gaps vs plan** — [`NEO-51-implementation-plan.md`](../plans/NEO-51-implementation-plan.md) lists unit tests for **wrong `prototypeRole` set** and **`equip_stub` with `stackMax` ≠ 1**. Coverage today includes incomplete ids and bag slot on equip_stub, but not those two cases. Add focused loader tests (AAA) so a regression in `PrototypeSlice1ItemCatalogRules` cannot slip through on role multiset or stack max alone.~~ **Done.**`Load_ShouldThrow_WhenSlice1PrototypeRolesDoNotMatch`, `Load_ShouldThrow_WhenEquipStubStackMaxIsNotOne` in `ItemDefinitionCatalogLoaderTests.cs`.
2. ~~**E3.M3 module page** — Add a short “Server load (NEO-51)” bullet under related implementation (or refresh the summary **Status**) pointing at `Game/Items/` and fail-fast boot, parallel to E2.M1s NEO-34 note in the alignment table. Keeps module doc and alignment doc in sync for readers who skip the register.~~ **Done.** — [E3_M3_ItemizationAndInventorySchema.md](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) **Server load (NEO-51)** bullet; register footnote names NEO-51.
3. ~~**Early-path loader tests (optional)** — Consider tests for missing items directory and missing schema file to lock the first `ThrowIfAny` messages; low priority because host boot test covers empty catalog dir.~~ **Done.**`Load_ShouldThrow_WhenItemsDirectoryMissing`, `Load_ShouldThrow_WhenSchemaFileMissing`.
## Nits
- ~~Nit: `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid` creates a temp directory and does not delete it (minor test hygiene).~~ **Done.**`finally` deletes `badDir`.
- Nit: `ItemDefinitionCatalog` copies `byId` into a new `Dictionary` then wraps `ReadOnlyDictionary` — fine for startup immutability; only worth revisiting if catalogs grow large.
- Nit: Slice 1 id error strings use single-quoted lists; Python CI uses `repr()` — behavior equivalent, messages differ cosmetically in logs.
## Verification
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ItemDefinitionCatalog"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual (optional):
```bash
cd server/NeonSprawl.Server && dotnet run
# GET http://localhost:5253/health — expect 200 after Information log with item count
```
Bruno: `bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru` after server is up.

View File

@ -4,6 +4,9 @@
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
(post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot NEO-46)
- item catalogs: content/items/*_items.json rows vs content/schemas/item-def.schema.json (NEO-50)
"""
from __future__ import annotations
@ -17,11 +20,33 @@ 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"
ITEM_SCHEMA = REPO_ROOT / "content/schemas/item-def.schema.json"
SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items"
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
# Slice 1 prototype lock (NEO-50): exact item ids + prototypeRole coverage after schema passes.
PROTOTYPE_SLICE1_ITEM_IDS = frozenset(
{
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
}
)
PROTOTYPE_SLICE1_ITEM_ROLES = frozenset(
{"material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"}
)
# 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 +70,307 @@ def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, s
return None
def _tier_index_gate(rel: str, track_index: int, tier_index_values: list[int]) -> str | None:
"""Keep in sync with MasteryCatalogLoader.TryGetTierIndexGateError (NEO-46)."""
if not tier_index_values:
return None
seen: set[int] = set()
for value in tier_index_values:
if value in seen:
return f"error: {rel}: tracks[{track_index}] duplicate tierIndex {value}"
seen.add(value)
expected = set(range(1, len(tier_index_values) + 1))
if seen != expected:
sorted_vals = ", ".join(str(v) for v in sorted(seen))
n = len(tier_index_values)
return (
f"error: {rel}: tracks[{track_index}] tierIndex values must be unique and "
f"sequential 1..{n}, got [{sorted_vals}]"
)
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
tier_index_values: list[int] = []
for tier_i, tier in enumerate(tiers):
if not isinstance(tier, dict):
continue
tier_index = tier.get("tierIndex")
if isinstance(tier_index, int):
tier_index_values.append(tier_index)
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
tier_index_err = _tier_index_gate(rel, ti, tier_index_values)
if tier_index_err:
print(tier_index_err, file=sys.stderr)
errors += 1
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_slice1_item_gate(
seen_ids: dict[str, str],
id_to_role: dict[str, str],
id_to_slot_kind: dict[str, str],
id_to_stack_max: dict[str, int],
) -> str | None:
"""Return a human-readable error if Slice 1 item contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_SLICE1_ITEM_IDS:
return (
"error: prototype Slice 1 expects exactly item ids "
f"{sorted(PROTOTYPE_SLICE1_ITEM_IDS)!r}, got {sorted(ids)!r}"
)
roles = set(id_to_role.values())
if roles != PROTOTYPE_SLICE1_ITEM_ROLES:
return (
"error: prototype Slice 1 requires exactly one row per prototypeRole "
f"{sorted(PROTOTYPE_SLICE1_ITEM_ROLES)!r}, roles seen: {sorted(roles)!r}"
)
equip_stub_id = next((iid for iid, role in id_to_role.items() if role == "equip_stub"), None)
if equip_stub_id is not None:
if id_to_slot_kind.get(equip_stub_id) != "equipment":
return (
f"error: {equip_stub_id!r} (equip_stub) must use inventorySlotKind 'equipment', "
f"got {id_to_slot_kind.get(equip_stub_id)!r}"
)
if id_to_stack_max.get(equip_stub_id) != 1:
return (
f"error: {equip_stub_id!r} (equip_stub) must use stackMax 1, "
f"got {id_to_stack_max.get(equip_stub_id)!r}"
)
return None
def _validate_item_catalogs(
*,
item_files: list[Path],
item_validator: Draft202012Validator,
) -> tuple[int, dict[str, str], dict[str, str], dict[str, str], dict[str, int]]:
"""Validate item JSON files. Returns (error_count, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max)."""
errors = 0
seen_ids: dict[str, str] = {}
id_to_role: dict[str, str] = {}
id_to_slot_kind: dict[str, str] = {}
id_to_stack_max: dict[str, int] = {}
for path in item_files:
rel = str(path.relative_to(REPO_ROOT))
data = json.loads(path.read_text(encoding="utf-8"))
schema_version = data.get("schemaVersion")
if schema_version != 1:
print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr)
errors += 1
continue
items = data.get("items")
if not isinstance(items, list):
print(f"error: {rel}: expected top-level 'items' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(items):
if not isinstance(row, dict):
print(f"error: {rel}: items[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(item_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} items[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
iid = row.get("id")
if isinstance(iid, str) and row_schema_errors == 0:
prev = seen_ids.get(iid)
if prev:
print(f"error: duplicate item id {iid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[iid] = rel
role = row.get("prototypeRole")
if isinstance(role, str):
id_to_role[iid] = role
slot_kind = row.get("inventorySlotKind")
if isinstance(slot_kind, str):
id_to_slot_kind[iid] = slot_kind
stack_max = row.get("stackMax")
if isinstance(stack_max, int):
id_to_stack_max[iid] = stack_max
return errors, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max
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 +378,21 @@ 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
if not ITEM_SCHEMA.is_file():
print(f"error: missing schema {ITEM_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)
item_schema = json.loads(ITEM_SCHEMA.read_text(encoding="utf-8"))
item_validator = Draft202012Validator(item_schema)
if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -72,6 +408,24 @@ 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
if not ITEMS_DIR.is_dir():
print(f"error: missing directory {ITEMS_DIR}", file=sys.stderr)
return 1
item_files = sorted(ITEMS_DIR.glob("*_items.json"))
if not item_files:
print(f"error: no *_items.json files under {ITEMS_DIR}", file=sys.stderr)
return 1
seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {}
errors = 0
@ -188,11 +542,44 @@ 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
item_errors, item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max = _validate_item_catalogs(
item_files=item_files,
item_validator=item_validator,
)
if item_errors:
print(f"content validation failed with {item_errors} error(s)", file=sys.stderr)
return 1
slice1_item_err = _prototype_slice1_item_gate(
item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max
)
if slice1_item_err:
print(slice1_item_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(item_files)} item catalog file(s), "
f"{len(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), "
f"{len(track_skill_ids)} mastery track(s)"
)
return 0

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
@ -25,7 +26,11 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services =>
{
@ -35,6 +40,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -52,6 +58,7 @@ public sealed class SalvageActivityDeniedRegistryWebApplicationFactory : WebAppl
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, SalvageActivityDeniedSkillRegistry>();
});

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Tests.Game.Items;
internal static class ItemCatalogTestPaths
{
internal static string DiscoverRepoItemsDirectory() =>
ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoItemDefSchemaPath() =>
ItemCatalogPathResolution.ResolveItemDefSchemaPath(
DiscoverRepoItemsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,357 @@
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
public class ItemDefinitionCatalogLoaderTests
{
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "refined_plate_stock",
"displayName": "Refined Plate Stock",
"prototypeRole": "intermediate",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "field_stim_mk0",
"displayName": "Field Stim Mk0",
"prototypeRole": "consumable",
"stackMax": 20,
"inventorySlotKind": "bag"
},
{
"id": "survey_drone_kit",
"displayName": "Survey Drone Kit",
"prototypeRole": "utility",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "contract_handoff_token",
"displayName": "Contract Handoff Token",
"prototypeRole": "quest_token",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "prototype_armor_shell",
"displayName": "Prototype Armor Shell",
"prototypeRole": "equip_stub",
"stackMax": 1,
"inventorySlotKind": "equipment"
}
]
}
""";
private static (string Root, string ItemsDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-itemcat-");
var itemsDir = Path.Combine(root.FullName, "content", "items");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(itemsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "item-def.schema.json");
File.Copy(ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, itemsDir, schemaPath);
}
private static void WriteCatalog(string itemsDir, string catalogJson) =>
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), catalogJson, Encoding.UTF8);
private static JsonObject GetItemRow(JsonObject catalogRoot, string itemId)
{
var items = catalogRoot["items"] as JsonArray
?? throw new InvalidOperationException("expected items array");
foreach (var node in items)
{
if (node is JsonObject row && row["id"]?.GetValue<string>() == itemId)
return row;
}
throw new InvalidOperationException($"item id not found: {itemId}");
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var catalog = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(6, catalog.DistinctItemCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetItem("scrap_metal_bulk", out var row));
Assert.NotNull(row);
Assert.Equal("material", row!.PrototypeRole);
Assert.Equal(999, row.StackMax);
}
[Fact]
public void Load_ShouldThrow_WhenItemsIsNotArray()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(itemsDir, "bad_items.json"),
"""{"schemaVersion": 1, "items": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'items' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(itemsDir, "bad_items.json"),
"""{"schemaVersion": 2, "items": []}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
const string bad = """
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
}
]
}
""";
File.WriteAllText(Path.Combine(itemsDir, "bad_items.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Item catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
const string singleItem = """
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
}
]
}
""";
File.WriteAllText(Path.Combine(itemsDir, "a_items.json"), singleItem, Encoding.UTF8);
File.WriteAllText(Path.Combine(itemsDir, "b_items.json"), singleItem, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate item id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal);
Assert.Contains("a_items.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("b_items.json", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice1IdsIncomplete()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
const string twoOnly = """
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "refined_plate_stock",
"displayName": "Refined Plate Stock",
"prototypeRole": "intermediate",
"stackMax": 999,
"inventorySlotKind": "bag"
}
]
}
""";
File.WriteAllText(Path.Combine(itemsDir, "partial_items.json"), twoOnly, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 1 expects exactly item ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenEquipStubUsesBagSlot()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
var badEquip = ValidPrototypeCatalogJson.Replace(
"\"inventorySlotKind\": \"equipment\"",
"\"inventorySlotKind\": \"bag\"",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), badEquip, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("equip_stub", ioe.Message, StringComparison.Ordinal);
Assert.Contains("inventorySlotKind 'equipment'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice1PrototypeRolesDoNotMatch()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetItemRow(root, "refined_plate_stock")["prototypeRole"] = "material";
WriteCatalog(itemsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requires exactly one row per prototypeRole", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenEquipStubStackMaxIsNotOne()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetItemRow(root, "prototype_armor_shell")["stackMax"] = 5;
WriteCatalog(itemsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype_armor_shell", ioe.Message, StringComparison.Ordinal);
Assert.Contains("stackMax 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenItemsDirectoryMissing()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-items-" + Guid.NewGuid().ToString("n"));
var schemaPath = ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath();
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(missingDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaFileMissing()
{
// Arrange
var (_, itemsDir, _) = CreateTempContentLayout();
var missingSchema = Path.Combine(itemsDir, "missing-item-def.schema.json");
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, missingSchema, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
}
[Fact]
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var catalog = factory.Services.GetRequiredService<ItemDefinitionCatalog>();
Assert.Equal(6, catalog.DistinctItemCount);
Assert.True(catalog.TryGetItem("prototype_armor_shell", out var shell));
Assert.Equal("equipment", shell!.InventorySlotKind);
}
[Fact]
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-items-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:ItemsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}

View File

@ -0,0 +1,405 @@
using System.Collections.Frozen;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public class MasteryCatalogLoaderTests
{
private static readonly FrozenSet<string> PrototypeSkillIds = FrozenSet.ToFrozenSet(
["salvage", "refine", "intrusion"],
StringComparer.Ordinal);
private const string ValidSalvageMasteryJson =
"""
{
"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", "perkIds": ["salvage_scrap_efficiency_1"] },
{ "branchId": "bulk_haul", "perkIds": ["salvage_bulk_haul_1"] }
]
}
]
}
]
}
""";
private sealed class TempContentLayout : IDisposable
{
public string MasteryDir { get; }
public string SchemaPath { get; }
private readonly string _root;
private TempContentLayout(string root, string masteryDir, string schemaPath)
{
_root = root;
MasteryDir = masteryDir;
SchemaPath = schemaPath;
}
public static TempContentLayout Create()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-masterycat-");
var masteryDir = Path.Combine(root.FullName, "content", "mastery");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(masteryDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "mastery-catalog.schema.json");
File.Copy(MasteryCatalogTestPaths.DiscoverRepoMasteryCatalogSchemaPath(), schemaPath, overwrite: true);
return new TempContentLayout(root.FullName, masteryDir, schemaPath);
}
public void Dispose()
{
try
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup for parallel test runs.
}
}
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
using var layout = TempContentLayout.Create();
File.WriteAllText(
Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"),
ValidSalvageMasteryJson,
Encoding.UTF8);
// Act
var catalog = MasteryCatalogLoader.Load(
layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance);
// Assert
Assert.Equal(1, catalog.TrackCount);
Assert.Equal(2, catalog.PerkCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
}
[Fact]
public void Load_ShouldThrow_WhenJsonIsMalformed()
{
// Arrange
using var layout = TempContentLayout.Create();
File.WriteAllText(Path.Combine(layout.MasteryDir, "bad_mastery.json"), "{ not json", Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenTracksIsNotArray()
{
// Arrange
using var layout = TempContentLayout.Create();
File.WriteAllText(
Path.Combine(layout.MasteryDir, "bad_mastery.json"),
"""{"schemaVersion":1,"perks":{},"tracks":"nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Mastery catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
using var layout = TempContentLayout.Create();
var bad = ValidSalvageMasteryJson.Replace(
"\"displayName\": \"Scrap Sifter\"",
"\"displayName\": \"\"",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "schema_bad_mastery.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("schema_bad_mastery.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Mastery catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnknownSkillId()
{
// Arrange
using var layout = TempContentLayout.Create();
var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal);
File.WriteAllText(
Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"),
ValidSalvageMasteryJson,
Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, onlyRefineSkills, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not a known SkillDef id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicatePerkIdAcrossFiles()
{
// Arrange
using var layout = TempContentLayout.Create();
File.WriteAllText(Path.Combine(layout.MasteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
File.WriteAllText(Path.Combine(layout.MasteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate perk id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage_scrap_efficiency_1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicatePerkIdReferenceAcrossBranches()
{
// Arrange
using var layout = TempContentLayout.Create();
var duplicateRef = ValidSalvageMasteryJson.Replace(
"\"perkIds\": [\"salvage_bulk_haul_1\"]",
"\"perkIds\": [\"salvage_scrap_efficiency_1\"]",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_ref_mastery.json"), duplicateRef, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate perk id reference", ioe.Message, StringComparison.Ordinal);
Assert.Contains("salvage_scrap_efficiency_1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenUnreferencedPerkInMap()
{
// Arrange
using var layout = TempContentLayout.Create();
var orphanPerk = ValidSalvageMasteryJson.Replace(
"\"salvage_bulk_haul_1\":",
"\"salvage_orphan_1\": { \"id\": \"salvage_orphan_1\", \"displayName\": \"Orphan\" },\n \"salvage_bulk_haul_1\":",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("not referenced by any branch perkIds", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenBranchIdSetDiffersBetweenTiers()
{
// Arrange
using var layout = TempContentLayout.Create();
var mismatch = ValidSalvageMasteryJson.Replace(
"\"branchId\": \"bulk_haul\", \"perkIds\": [\"salvage_bulk_haul_1\"]",
"\"branchId\": \"other_branch\", \"perkIds\": [\"salvage_bulk_haul_1\"]",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "branch_mismatch_mastery.json"), mismatch, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("branchId set", ioe.Message, StringComparison.Ordinal);
Assert.Contains("must match previous tier", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRequiredLevelNotStrictlyIncreasing()
{
// Arrange
using var layout = TempContentLayout.Create();
var flatLevel = ValidSalvageMasteryJson.Replace("\"requiredLevel\": 4", "\"requiredLevel\": 2", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "flat_level_mastery.json"), flatLevel, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requiredLevel must be strictly greater", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice4TrackCountWrong()
{
// Arrange
using var layout = TempContentLayout.Create();
const string twoTracksJson = """
{
"schemaVersion": 1,
"perks": {},
"tracks": [
{
"skillId": "salvage",
"tiers": [
{
"tierIndex": 1,
"requiredLevel": 2,
"branches": [
{ "branchId": "a", "perkIds": [] },
{ "branchId": "b", "perkIds": [] }
]
}
]
},
{
"skillId": "refine",
"tiers": [
{
"tierIndex": 1,
"requiredLevel": 2,
"branches": [
{ "branchId": "a", "perkIds": [] },
{ "branchId": "b", "perkIds": [] }
]
}
]
}
]
}
""";
File.WriteAllText(Path.Combine(layout.MasteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 4 expects exactly one MasteryTrack", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice4SoleTrackSkillIdWrong()
{
// Arrange
using var layout = TempContentLayout.Create();
var refineTrack = ValidSalvageMasteryJson.Replace("\"skillId\": \"salvage\"", "\"skillId\": \"refine\"", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "refine_track_mastery.json"), refineTrack, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 4 expects the sole track skillId", ioe.Message, StringComparison.Ordinal);
Assert.Contains("refine", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenTierIndexDuplicate()
{
// Arrange
using var layout = TempContentLayout.Create();
var dupTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 1", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_tier_mastery.json"), dupTier, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate tierIndex", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenTierIndexHasGap()
{
// Arrange
using var layout = TempContentLayout.Create();
var gapTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 3", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(layout.MasteryDir, "gap_tier_mastery.json"), gapTier, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("tierIndex values must be unique and sequential", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Host_ShouldFailStartup_WhenMasteryDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n"));
try
{
Directory.CreateDirectory(badDir);
var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory();
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseSetting("Content:SkillsDirectory", skillsDir);
b.UseSetting("Content:MasteryDirectory", badDir);
});
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Mastery catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
try
{
if (Directory.Exists(badDir))
Directory.Delete(badDir, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup.
}
}
}
}

View File

@ -0,0 +1,140 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public class MasteryCatalogRegistryTests
{
private static MasteryCatalogRegistry CreateRegistryFromCatalog(MasteryCatalog catalog) =>
new(catalog);
[Fact]
public void TryGetTrack_ShouldReturnTrue_WhenSkillIdExists()
{
// Arrange
var track = new MasteryTrackRow(
"salvage",
[new MasteryTierRow(1, 2, [new MasteryBranchRow("scrap_efficiency", null, [])])]);
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal) { ["salvage"] = track },
new Dictionary<string, PerkDefRow>(StringComparer.Ordinal),
catalogJsonFileCount: 1);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var found = registry.TryGetTrack("salvage", out var result);
// Assert
Assert.True(found);
Assert.NotNull(result);
Assert.Equal("salvage", result.SkillId);
}
[Fact]
public void TryGetTrack_ShouldReturnFalse_WhenSkillIdUnknown()
{
// Arrange
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal),
new Dictionary<string, PerkDefRow>(StringComparer.Ordinal),
catalogJsonFileCount: 0);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var found = registry.TryGetTrack("not_a_track", out var result);
// Assert
Assert.False(found);
Assert.Null(result);
}
[Fact]
public void TryGetPerk_ShouldReturnTrue_WhenPerkIdExists()
{
// Arrange
var perk = new PerkDefRow("salvage_scrap_efficiency_1", "Scrap Sifter", "gather_yield_modifier");
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal),
new Dictionary<string, PerkDefRow>(StringComparer.Ordinal) { [perk.Id] = perk },
catalogJsonFileCount: 1);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var found = registry.TryGetPerk("salvage_scrap_efficiency_1", out var result);
// Assert
Assert.True(found);
Assert.NotNull(result);
Assert.Equal("gather_yield_modifier", result.EffectKind);
}
[Fact]
public void GetTracksInSkillIdOrder_ShouldReturnTracksSortedBySkillId()
{
// Arrange
var refine = new MasteryTrackRow("refine", []);
var salvage = new MasteryTrackRow("salvage", []);
var intrusion = new MasteryTrackRow("intrusion", []);
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal)
{
["refine"] = refine,
["salvage"] = salvage,
["intrusion"] = intrusion,
},
new Dictionary<string, PerkDefRow>(StringComparer.Ordinal),
catalogJsonFileCount: 1);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var tracks = registry.GetTracksInSkillIdOrder();
// Assert
Assert.Equal(["intrusion", "refine", "salvage"], tracks.Select(t => t.SkillId).ToArray());
}
[Fact]
public void GetPerksInIdOrder_ShouldReturnPerksSortedById()
{
// Arrange
var perks = new Dictionary<string, PerkDefRow>(StringComparer.Ordinal)
{
["z_perk"] = new("z_perk", "Z", null),
["a_perk"] = new("a_perk", "A", null),
["m_perk"] = new("m_perk", "M", null),
};
var catalog = new MasteryCatalog(
"/tmp/mastery",
new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal),
perks,
catalogJsonFileCount: 1);
var registry = CreateRegistryFromCatalog(catalog);
// Act
var ordered = registry.GetPerksInIdOrder();
// Assert
Assert.Equal(["a_perk", "m_perk", "z_perk"], ordered.Select(p => p.Id).ToArray());
}
[Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/health");
var registry = factory.Services.GetRequiredService<IMasteryCatalogRegistry>();
var catalog = factory.Services.GetRequiredService<MasteryCatalog>();
var foundTrack = registry.TryGetTrack("salvage", out var track);
var foundPerk = registry.TryGetPerk("salvage_scrap_efficiency_1", out var perk);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(1, catalog.TrackCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
Assert.True(foundTrack);
Assert.NotNull(track);
Assert.Equal(2, track.Tiers.Count);
Assert.True(foundPerk);
Assert.NotNull(perk);
Assert.Equal("Scrap Sifter", perk.DisplayName);
}
}

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.Mastery;
namespace NeonSprawl.Server.Tests.Game.Mastery;
internal static class MasteryCatalogTestPaths
{
internal static string DiscoverRepoMasteryDirectory() =>
MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoMasteryCatalogSchemaPath() =>
MasteryCatalogPathResolution.ResolveMasteryCatalogSchemaPath(
DiscoverRepoMasteryDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,178 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public sealed class MasteryFixtureApiTests
{
private const string DevPlayer = "dev-local-1";
private const string FixturePath = $"/game/players/{DevPlayer}/__dev/mastery-fixture";
private static MasteryFixtureRequest ValidFixture(
bool resetPerkState = true,
Dictionary<string, int>? skillXp = null) =>
new MasteryFixtureRequest
{
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion,
ResetPerkState = resetPerkState,
SkillXp = skillXp ?? new Dictionary<string, int> { ["salvage"] = 0 },
};
private static async Task ArrangeSalvageProgressedWithTier1PickAsync(HttpClient client)
{
var grant = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = "salvage",
Amount = 100,
SourceKind = "activity",
});
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
var pick = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
new PerkBranchSelectRequest
{
SchemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion,
SkillId = "salvage",
TierIndex = 1,
BranchId = "scrap_efficiency",
});
Assert.Equal(HttpStatusCode.OK, pick.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenRouteNotRegistered()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.WithWebHostBuilder(b =>
{
b.UseEnvironment("Production");
b.UseSetting("Game:EnableMasteryFixtureApi", "false");
}).CreateClient();
// Act
var response = await client.PostAsJsonAsync(FixturePath, ValidFixture());
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
FixturePath,
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = ValidFixture();
request = new MasteryFixtureRequest
{
SchemaVersion = MasteryFixtureRequest.CurrentSchemaVersion + 99,
ResetPerkState = request.ResetPerkState,
SkillXp = request.SkillXp,
};
// Act
var response = await client.PostAsJsonAsync(FixturePath, request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnBadRequest_WhenSkillXpNegative()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
FixturePath,
ValidFixture(skillXp: new Dictionary<string, int> { ["salvage"] = -1 }));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/__dev/mastery-fixture",
ValidFixture());
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostMasteryFixture_ShouldResetSalvageXpAndPerkState_ThenLevelTooLowDenies()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await ArrangeSalvageProgressedWithTier1PickAsync(client);
// Act
var reset = await client.PostAsJsonAsync(FixturePath, ValidFixture());
var deny = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
new PerkBranchSelectRequest
{
SchemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion,
SkillId = "salvage",
TierIndex = 1,
BranchId = "scrap_efficiency",
});
// Assert
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
var resetBody = await reset.Content.ReadFromJsonAsync<MasteryFixtureResponse>();
Assert.NotNull(resetBody);
Assert.True(resetBody!.Applied);
var progression = await client.GetFromJsonAsync<SkillProgressionSnapshotResponse>(
$"/game/players/{DevPlayer}/skill-progression");
var salvage = Assert.Single(progression!.Skills!, static s => s.Id == "salvage");
Assert.Equal(0, salvage.Xp);
Assert.Equal(1, salvage.Level);
var perkGet = await client.GetFromJsonAsync<PerkStateSnapshotResponse>(
$"/game/players/{DevPlayer}/perk-state");
Assert.Empty(perkGet!.BranchPicks);
Assert.Equal(HttpStatusCode.OK, deny.StatusCode);
var envelope = await deny.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, envelope.ReasonCode);
}
}

View File

@ -0,0 +1,311 @@
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public sealed class PerkStateApiTests
{
private const string DevPlayer = "dev-local-1";
private static PerkBranchSelectRequest SelectBranch(
string skillId,
int tierIndex,
string branchId,
int schemaVersion = PerkBranchSelectRequest.CurrentSchemaVersion) =>
new PerkBranchSelectRequest
{
SchemaVersion = schemaVersion,
SkillId = skillId,
TierIndex = tierIndex,
BranchId = branchId,
};
private static SkillProgressionGrantRequest SalvageGrant(int amount) =>
new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = "salvage",
Amount = amount,
SourceKind = "activity",
};
[Fact]
public async Task GetPerkState_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/players/missing-player/perk-state");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetPerkState_ShouldReturnSchemaV1_WithEmptyState_ForDevPlayer()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync($"/game/players/{DevPlayer}/perk-state");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PerkStateSnapshotResponse>();
Assert.NotNull(body);
Assert.Equal(PerkStateSnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.Equal(DevPlayer, body.PlayerId);
Assert.Empty(body.BranchPicks);
Assert.Empty(body.UnlockedPerkIds);
}
[Fact]
public async Task PostPerkState_ShouldReturnBadRequest_WhenBodyNull()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsync(
$"/game/players/{DevPlayer}/perk-state",
new StringContent(string.Empty, Encoding.UTF8, "application/json"));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldReturnBadRequest_WhenSchemaVersionMismatch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var request = SelectBranch("salvage", 1, "scrap_efficiency", PerkBranchSelectRequest.CurrentSchemaVersion + 42);
// Act
var response = await client.PostAsJsonAsync($"/game/players/{DevPlayer}/perk-state", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldReturnNotFound_WhenPlayerUnknown()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/missing-player/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyLevelTooLow_WhenSalvageBelowTierGate()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, envelope.ReasonCode);
Assert.Empty(envelope.UnlockedEvents);
Assert.Empty(envelope.PerkState.BranchPicks);
}
[Fact]
public async Task PostPerkState_ShouldSelectTier1_AndGetReflectsPick_AfterSalvageLevel2()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var grant = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(100));
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
// Act
var post = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var json = await post.Content.ReadAsStringAsync();
using (var doc = JsonDocument.Parse(json))
{
Assert.False(doc.RootElement.TryGetProperty("reasonCode", out _));
}
var envelope = JsonSerializer.Deserialize<PerkBranchSelectResponse>(json);
Assert.NotNull(envelope);
Assert.True(envelope!.Selected);
Assert.Null(envelope.ReasonCode);
Assert.Empty(envelope.UnlockedEvents);
var track = Assert.Single(envelope.PerkState.BranchPicks);
Assert.Equal("salvage", track.SkillId);
var pick = Assert.Single(track.Picks);
Assert.Equal(1, pick.TierIndex);
Assert.Equal("scrap_efficiency", pick.BranchId);
Assert.Empty(envelope.PerkState.UnlockedPerkIds);
var get = await client.GetFromJsonAsync<PerkStateSnapshotResponse>($"/game/players/{DevPlayer}/perk-state");
Assert.NotNull(get);
var getTrack = Assert.Single(get!.BranchPicks);
Assert.Equal("scrap_efficiency", Assert.Single(getTrack.Picks).BranchId);
}
private static async Task GrantSalvageLevel2Async(HttpClient client)
{
var grant = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(100));
Assert.Equal(HttpStatusCode.OK, grant.StatusCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyUnknownTrack_WhenSkillIdInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("refine", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.UnknownTrack, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyUnknownTier_WhenTierIndexInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 99, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.UnknownTier, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyUnknownBranch_WhenBranchIdInvalid()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "not_a_branch"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.UnknownBranch, envelope.ReasonCode);
}
[Fact]
public async Task PostPerkState_ShouldDenyBranchAlreadyChosen_OnSecondPick()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
await GrantSalvageLevel2Async(client);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"))).StatusCode);
// Act
var response = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "bulk_haul"));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var envelope = await response.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.False(envelope!.Selected);
Assert.Equal(PerkUnlockReasonCodes.BranchAlreadyChosen, envelope.ReasonCode);
Assert.Equal("scrap_efficiency", Assert.Single(Assert.Single(envelope.PerkState.BranchPicks).Picks).BranchId);
}
[Fact]
public async Task PostPerkState_ShouldEmitUnlockedEvents_WhenTier2PerkUnlocksAtLevel4()
{
// Arrange — 100 + 400 XP → level 4 before tier-1 pick; path-auto tier 2 on same POST
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(100))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/skill-progression",
SalvageGrant(400))).StatusCode);
// Act
var post = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/perk-state",
SelectBranch("salvage", 1, "scrap_efficiency"));
// Assert
Assert.Equal(HttpStatusCode.OK, post.StatusCode);
var envelope = await post.Content.ReadFromJsonAsync<PerkBranchSelectResponse>();
Assert.NotNull(envelope);
Assert.True(envelope!.Selected);
var unlocked = Assert.Single(envelope.UnlockedEvents);
Assert.Equal("salvage_scrap_efficiency_1", unlocked.PerkId);
Assert.Equal("salvage", unlocked.SkillId);
Assert.Equal(2, unlocked.TierIndex);
Assert.Equal("scrap_efficiency", unlocked.BranchId);
Assert.Equal("level_up", unlocked.Source);
Assert.Contains("salvage_scrap_efficiency_1", envelope.PerkState.UnlockedPerkIds);
}
}

View File

@ -0,0 +1,85 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Tests.Game.PositionState;
using Npgsql;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
[Collection("Postgres integration")]
public sealed class PerkStatePersistenceIntegrationTests(PostgresIntegrationHarness harness)
{
private PostgresWebApplicationFactory Factory => harness.Factory;
[RequirePostgresFact]
public async Task BranchPickAndUnlock_ShouldPersistAcrossNewFactory()
{
// Arrange
await ResetPerkTablesAsync();
const string playerId = "dev-local-1";
using (var scope = Factory.Services.CreateScope())
{
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var engine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
Assert.True(xpStore.TryApplyXpDelta(playerId, "salvage", 450, out _, out _));
Assert.True(engine.TrySelectBranch(playerId, "salvage", 1, "scrap_efficiency").Success);
_ = engine.ReevaluateAfterLevelUp(playerId, "salvage", newLevel: 4);
}
// Act
PerkStateSnapshot snapshot;
await using (var secondFactory = new PostgresWebApplicationFactory())
{
using var scope = secondFactory.Services.CreateScope();
var perkStore = scope.ServiceProvider.GetRequiredService<IPlayerPerkStateStore>();
snapshot = perkStore.GetSnapshot(playerId);
}
// Assert
Assert.Equal("scrap_efficiency", snapshot.BranchPicksBySkillId["salvage"][1]);
Assert.Contains("salvage_scrap_efficiency_1", snapshot.UnlockedPerkIds);
}
private async Task ResetPerkTablesAsync()
{
var cs = Environment.GetEnvironmentVariable("ConnectionStrings__NeonSprawl");
if (string.IsNullOrWhiteSpace(cs))
{
throw new InvalidOperationException("ConnectionStrings__NeonSprawl is not set.");
}
_ = Factory.Services;
var options = Factory.Services.GetRequiredService<IOptions<GamePositionOptions>>().Value;
var positionDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V001__player_position.sql");
var perkDdlPath = Path.Combine(AppContext.BaseDirectory, "db", "migrations", "V004__player_perk_state.sql");
if (!File.Exists(positionDdlPath) || !File.Exists(perkDdlPath))
{
throw new FileNotFoundException("Test DDL for perk persistence not found.");
}
var positionDdl = await File.ReadAllTextAsync(positionDdlPath);
var perkDdl = await File.ReadAllTextAsync(perkDdlPath);
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
await using (var applyPosition = new NpgsqlCommand(positionDdl, conn))
{
await applyPosition.ExecuteNonQueryAsync();
}
await using (var truncate = new NpgsqlCommand("TRUNCATE player_position CASCADE;", conn))
{
await truncate.ExecuteNonQueryAsync();
}
PostgresPositionBootstrap.SeedDevPlayer(conn, options);
await using (var applyPerk = new NpgsqlCommand(perkDdl, conn))
{
await applyPerk.ExecuteNonQueryAsync();
}
}
}

View File

@ -0,0 +1,287 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Mastery;
public sealed class PerkUnlockEngineTests
{
private const string DevPlayer = "dev-local-1";
private const string UnknownPlayer = "no-such-player-neo-47";
private const string SalvageSkill = "salvage";
private const string ScrapBranch = "scrap_efficiency";
private const string BulkBranch = "bulk_haul";
private const string ScrapPerk = "salvage_scrap_efficiency_1";
private const string BulkPerk = "salvage_bulk_haul_1";
private static (PerkUnlockEngine Engine, IPlayerSkillProgressionStore XpStore, IPlayerPerkStateStore PerkStore) CreateEngine(
InMemoryWebApplicationFactory factory) =>
(
factory.Services.GetRequiredService<PerkUnlockEngine>(),
factory.Services.GetRequiredService<IPlayerSkillProgressionStore>(),
factory.Services.GetRequiredService<IPlayerPerkStateStore>());
[Fact]
public async Task TrySelectBranch_AtSalvageLevel2_ShouldRecordPick_WithoutUnlockingTier2Perks()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
// Assert
Assert.True(outcome.Success);
Assert.Null(outcome.ReasonCode);
Assert.Empty(outcome.Events);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Equal(ScrapBranch, snap.BranchPicksBySkillId[SalvageSkill][1]);
Assert.DoesNotContain(ScrapPerk, snap.UnlockedPerkIds);
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
}
[Fact]
public async Task TrySelectBranch_AtLevel4AfterXpGate_ShouldUnlockTier2PerkWithoutExtraGrant()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
// Assert
Assert.True(outcome.Success);
var perk = Assert.Single(outcome.Events);
Assert.Equal(ScrapPerk, perk.PerkId);
Assert.Equal(PerkUnlockSource.LevelUp, perk.Source);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
Assert.DoesNotContain(BulkPerk, snap.UnlockedPerkIds);
}
[Fact]
public async Task TrySelectBranch_WhenLevelTooLow_ShouldDenyWithLevelTooLow()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, _, _) = CreateEngine(factory);
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.LevelTooLow, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenUnknownTrack_ShouldDenyWithUnknownTrack()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, "refine", tierIndex: 1, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.UnknownTrack, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenUnknownTier_ShouldDenyWithUnknownTier()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 99, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.UnknownTier, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenPlayerNotInStore_ShouldDenyWithPlayerNotFound()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, _, _) = CreateEngine(factory);
// Act
var outcome = engine.TrySelectBranch(UnknownPlayer, SalvageSkill, tierIndex: 1, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.PlayerNotFound, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenBranchAlreadyChosen_ShouldDenyWithBranchAlreadyChosen()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, BulkBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.BranchAlreadyChosen, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_WhenBranchUnknown_ShouldDenyWithUnknownBranch()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 1, "not_a_branch");
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.UnknownBranch, outcome.ReasonCode);
}
[Fact]
public async Task TrySelectBranch_OnPathAutoTier2_ShouldDenyWithTierBranchNotSelectable()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, _) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
// Act
var outcome = engine.TrySelectBranch(DevPlayer, SalvageSkill, tierIndex: 2, ScrapBranch);
// Assert
Assert.False(outcome.Success);
Assert.Equal(PerkUnlockReasonCodes.TierBranchNotSelectable, outcome.ReasonCode);
}
[Fact]
public async Task ReevaluateAfterLevelUp_AtLevel4WithTier1Pick_ShouldUnlockScrapPerkOnly()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
var snapAfterPick = perkStore.GetSnapshot(DevPlayer);
Assert.Contains(ScrapPerk, snapAfterPick.UnlockedPerkIds);
// Act
var outcome = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
// Assert
Assert.Empty(outcome.Events);
}
[Fact]
public async Task ReevaluateAfterLevelUp_WhenCalledTwice_ShouldBeIdempotent()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
_ = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 450, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
_ = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
// Act
var second = engine.ReevaluateAfterLevelUp(DevPlayer, SalvageSkill, newLevel: 4);
// Assert
Assert.Empty(second.Events);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Single(snap.UnlockedPerkIds);
}
[Fact]
public async Task PostSkillProgressionGrant_AfterTier1PickAndLevel4Xp_ShouldUnlockPerkViaHook()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 100, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
var grant = new SkillProgressionGrantRequest
{
SchemaVersion = SkillProgressionGrantRequest.CurrentSchemaVersion,
SkillId = SalvageSkill,
Amount = 350,
SourceKind = "activity",
};
// Act
var response = await client.PostAsJsonAsync($"/game/players/{DevPlayer}/skill-progression", grant);
// Assert
Assert.True(response.IsSuccessStatusCode);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
}
[Fact]
public async Task PostInteract_ResourceNode_AfterTier1PickNearLevel4_ShouldUnlockPerkViaGatherHook()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var (engine, xpStore, perkStore) = CreateEngine(factory);
Assert.True(xpStore.TryApplyXpDelta(DevPlayer, SalvageSkill, 440, out _, out _));
Assert.True(engine.TrySelectBranch(DevPlayer, SalvageSkill, 1, ScrapBranch).Success);
var move = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 10, Y = 0.9, Z = -6 },
};
await client.PostAsJsonAsync($"/game/players/{DevPlayer}/move", move);
// Act
var interact = await client.PostAsJsonAsync(
$"/game/players/{DevPlayer}/interact",
new InteractionRequest
{
SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
});
// Assert
Assert.True(interact.IsSuccessStatusCode);
var snap = perkStore.GetSnapshot(DevPlayer);
Assert.Contains(ScrapPerk, snap.UnlockedPerkIds);
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.PositionState;
@ -23,7 +24,11 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureAppConfiguration((_, config) =>
{

View File

@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Skills;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Skills;
public sealed class InMemoryPlayerSkillProgressionStoreTests
{
[Fact]
public void TrySetSkillXpTotal_WhenXpZeroAndSkillAbsent_ShouldReturnTrue()
{
// Arrange
using var factory = new InMemoryWebApplicationFactory();
var store = factory.Services.GetRequiredService<IPlayerSkillProgressionStore>();
// Act
var applied = store.TrySetSkillXpTotal("dev-local-1", "intrusion", xp: 0);
// Assert
Assert.True(applied);
Assert.False(store.GetXpTotals("dev-local-1").ContainsKey("intrusion"));
}
}

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
@ -25,7 +26,11 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services =>
{
@ -35,6 +40,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -52,6 +58,7 @@ public sealed class MissionRewardDeniedRegistryWebApplicationFactory : WebApplic
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, MissionRewardDeniedSkillRegistry>();
});

View File

@ -2,6 +2,7 @@ using System.Linq;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Xunit;
@ -23,6 +24,7 @@ public sealed class MissionRewardSkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act
MissionRewardSkillXpGrant.GrantFromMissionReward(
@ -31,7 +33,8 @@ public sealed class MissionRewardSkillXpGrantTests
TestMissionRewardXp,
registry,
xpStore,
levelCurve);
levelCurve,
perkEngine);
// Assert
var totals = xpStore.GetXpTotals("dev-local-1");
@ -55,6 +58,7 @@ public sealed class MissionRewardSkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act
MissionRewardSkillXpGrant.GrantFromMissionReward(
@ -63,7 +67,8 @@ public sealed class MissionRewardSkillXpGrantTests
TestMissionRewardXp,
registry,
xpStore,
levelCurve);
levelCurve,
perkEngine);
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
// Assert
@ -84,6 +89,7 @@ public sealed class MissionRewardSkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act
MissionRewardSkillXpGrant.GrantFromMissionReward(
@ -92,7 +98,8 @@ public sealed class MissionRewardSkillXpGrantTests
TestMissionRewardXp,
registry,
xpStore,
levelCurve);
levelCurve,
perkEngine);
// Assert — deny returns before TryApplyXpDelta; salvage row must not appear in stored XP map.
var totals = xpStore.GetXpTotals("dev-local-1");

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
@ -25,7 +26,11 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services =>
{
@ -35,6 +40,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(ISkillDefinitionRegistry) ||
@ -52,6 +58,7 @@ public sealed class RefineActivityDeniedRegistryWebApplicationFactory : WebAppli
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
services.AddSingleton<ISkillDefinitionRegistry, RefineActivityDeniedSkillRegistry>();
});

View File

@ -2,6 +2,7 @@ using System.Linq;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using Xunit;
@ -21,9 +22,15 @@ public sealed class RefineActivitySkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
"dev-local-1",
registry,
xpStore,
levelCurve,
perkEngine);
// Assert
var totals = xpStore.GetXpTotals("dev-local-1");
@ -47,9 +54,15 @@ public sealed class RefineActivitySkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
"dev-local-1",
registry,
xpStore,
levelCurve,
perkEngine);
var snapshot = await client.GetAsync("/game/players/dev-local-1/skill-progression");
// Assert
@ -70,9 +83,15 @@ public sealed class RefineActivitySkillXpGrantTests
var registry = scope.ServiceProvider.GetRequiredService<ISkillDefinitionRegistry>();
var xpStore = scope.ServiceProvider.GetRequiredService<IPlayerSkillProgressionStore>();
var levelCurve = scope.ServiceProvider.GetRequiredService<ISkillLevelCurve>();
var perkEngine = scope.ServiceProvider.GetRequiredService<PerkUnlockEngine>();
// Act
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine("dev-local-1", registry, xpStore, levelCurve);
RefineActivitySkillXpGrant.GrantOnSuccessfulCraftOrRefine(
"dev-local-1",
registry,
xpStore,
levelCurve,
perkEngine);
// Assert — deny returns before TryApplyXpDelta; refine row must not appear in stored XP map.
var totals = xpStore.GetXpTotals("dev-local-1");

View File

@ -1,6 +1,7 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using NeonSprawl.Server.Game.Skills;
using Xunit;
@ -120,6 +121,26 @@ public sealed class SkillProgressionGrantApiTests
Assert.Equal(SkillProgressionSnapshotApi.ReasonInvalidAmount, envelope.ReasonCode);
}
[Fact]
public async Task PostSkillProgression_ShouldSerializeReasonCodeNull_OnSuccess()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/skill-progression",
ValidGrant("salvage", amount: 10));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
Assert.True(doc.RootElement.TryGetProperty("reasonCode", out var reasonCode));
Assert.Equal(JsonValueKind.Null, reasonCode.ValueKind);
}
[Fact]
public async Task PostSkillProgression_ShouldApplyGrant_AndGetMatches_WhenTrainerAllowedForRefine()
{

View File

@ -5,7 +5,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
@ -26,7 +28,16 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>
{
@ -36,6 +47,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
if (d.ServiceType == typeof(IPositionStateStore) ||
d.ServiceType == typeof(IPlayerHotbarLoadoutStore) ||
d.ServiceType == typeof(IPlayerSkillProgressionStore) ||
d.ServiceType == typeof(IPlayerPerkStateStore) ||
d.ServiceType == typeof(TimeProvider) ||
d.ServiceType == typeof(IPlayerAbilityCooldownStore) ||
d.ServiceType == typeof(NpgsqlDataSource) ||
@ -53,6 +65,7 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
services.AddSingleton<IPositionStateStore, InMemoryPositionStateStore>();
services.AddSingleton<IPlayerHotbarLoadoutStore, InMemoryPlayerHotbarLoadoutStore>();
services.AddSingleton<IPlayerSkillProgressionStore, InMemoryPlayerSkillProgressionStore>();
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
services.AddSingleton<IPlayerAbilityCooldownStore, InMemoryPlayerAbilityCooldownStore>();
});
}

View File

@ -1,3 +1,4 @@
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.World;
@ -15,7 +16,8 @@ public static class InteractionApi
app.MapPost(
"/game/players/{id}/interact",
(string id, InteractionRequest? body, IPositionStateStore store,
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve) =>
ISkillDefinitionRegistry registry, IPlayerSkillProgressionStore xpStore, ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine) =>
{
if (body is null || body.SchemaVersion != InteractionRequest.CurrentSchemaVersion)
{
@ -79,7 +81,8 @@ public static class InteractionApi
GatherSkillXpConstants.ActivitySourceKind,
registry,
xpStore,
levelCurve);
levelCurve,
perkUnlockEngine);
}
return Results.Json(

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Resolves item catalog paths for local dev, tests, and container layouts (NEO-51).</summary>
public static class ItemCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/items</c> directory.</summary>
public static string? TryDiscoverItemsDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "items");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the items catalog directory.
/// Empty <paramref name="configuredItemsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveItemsDirectory(string? configuredItemsDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredItemsDirectory))
{
var discovered = TryDiscoverItemsDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:ItemsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/items'). " +
"Set Content:ItemsDirectory in configuration or environment (e.g. Content__ItemsDirectory).");
}
var trimmed = configuredItemsDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single item row (Draft 2020-12).</summary>
public static string ResolveItemDefSchemaPath(
string itemsDirectory,
string? configuredSchemaPath,
string contentRootPath)
{
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
{
var trimmed = configuredSchemaPath.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
return Path.GetFullPath(Path.Combine(itemsDirectory, "..", "schemas", "item-def.schema.json"));
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Items;
/// <summary>DI registration for the fail-fast item catalog (NEO-51).</summary>
public static class ItemCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<ItemDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Items.ItemCatalog");
var itemsDir = ItemCatalogPathResolution.ResolveItemsDirectory(opts.ItemsDirectory, hostEnv.ContentRootPath);
var schemaPath = ItemCatalogPathResolution.ResolveItemDefSchemaPath(
itemsDir,
opts.ItemDefSchemaPath,
hostEnv.ContentRootPath);
return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger);
});
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>One validated <c>ItemDef</c> row from <c>content/items/*_items.json</c> (NEO-51).</summary>
public sealed record ItemDefRow(
string Id,
string DisplayName,
string PrototypeRole,
int StackMax,
string InventorySlotKind,
string? Rarity,
string? BindPolicy,
int? DurabilityMax);

View File

@ -0,0 +1,25 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Items;
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52).</summary>
public sealed class ItemDefinitionCatalog(
string itemsDirectory,
IReadOnlyDictionary<string, ItemDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_items.json</c> catalogs.</summary>
public string ItemsDirectory { get; } = itemsDirectory;
public IReadOnlyDictionary<string, ItemDefRow> ById { get; } = new ReadOnlyDictionary<string, ItemDefRow>(new Dictionary<string, ItemDefRow>(byId, StringComparer.Ordinal));
public int DistinctItemCount => ById.Count;
/// <summary>Number of <c>*_items.json</c> files under <see cref="ItemsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable <paramref name="id"/>.</summary>
public bool TryGetItem(string id, out ItemDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -0,0 +1,222 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Items;
/// <summary>Loads and validates <c>content/items/*_items.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-51).</summary>
public static class ItemDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static ItemDefinitionCatalog Load(string itemsDirectory, string schemaPath, ILogger logger)
{
itemsDirectory = Path.GetFullPath(itemsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(itemsDirectory))
errors.Add($"error: missing directory {itemsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(itemsDirectory, "*_items.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_items.json files under {itemsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var itemIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var idToRole = new Dictionary<string, string>(StringComparer.Ordinal);
var idToSlotKind = new Dictionary<string, string>(StringComparer.Ordinal);
var idToStackMax = new Dictionary<string, int>(StringComparer.Ordinal);
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
JsonNode? root;
try
{
root = JsonNode.Parse(File.ReadAllText(path));
}
catch (JsonException ex)
{
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
continue;
}
if (root is not JsonObject rootObj)
{
errors.Add($"error: {path}: expected JSON object at root");
continue;
}
var schemaVersionNode = rootObj["schemaVersion"];
if (schemaVersionNode is not JsonValue schemaVersionValue ||
!schemaVersionValue.TryGetValue<int>(out var schemaVersion) ||
schemaVersion != 1)
{
var got = schemaVersionNode?.ToJsonString() ?? "null";
errors.Add($"error: {path}: expected schemaVersion 1, got {got}");
continue;
}
var itemsNode = rootObj["items"];
if (itemsNode is not JsonArray itemsArray)
{
errors.Add($"error: {path}: expected top-level 'items' array");
continue;
}
for (var i = 0; i < itemsArray.Count; i++)
{
var item = itemsArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: items[{i}] must be an object");
continue;
}
var eval = schema.Evaluate(rowObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} items[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var rowSchemaErrors = schemaMsgs.Count;
var iid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (iid is not null && rowSchemaErrors == 0)
{
if (itemIdToSourceFile.TryGetValue(iid, out var prevPath))
{
errors.Add($"error: duplicate item id '{iid}' in {prevPath} and {path}");
continue;
}
itemIdToSourceFile[iid] = path;
var role = (rowObj["prototypeRole"] as JsonValue)?.GetValue<string>();
if (role is not null)
idToRole[iid] = role;
var slotKind = (rowObj["inventorySlotKind"] as JsonValue)?.GetValue<string>();
if (slotKind is not null)
idToSlotKind[iid] = slotKind;
if (rowObj["stackMax"] is JsonValue stackMaxValue &&
stackMaxValue.TryGetValue<int>(out var stackMax))
{
idToStackMax[iid] = stackMax;
}
rows[iid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var slice1 = PrototypeSlice1ItemCatalogRules.TryGetSlice1GateError(
itemIdToSourceFile,
idToRole,
idToSlotKind,
idToStackMax);
if (slice1 is not null)
{
errors.Add(slice1);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded item catalog from {ItemsDirectory}: {ItemCount} item(s) across {CatalogFileCount} JSON catalog file(s).",
itemsDirectory,
rows.Count,
jsonFiles.Length);
}
return new ItemDefinitionCatalog(itemsDirectory, rows, jsonFiles.Length);
}
private static ItemDefRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prototypeRole = (rowObj["prototypeRole"] as JsonValue)!.GetValue<string>();
var stackMax = (rowObj["stackMax"] as JsonValue)!.GetValue<int>();
var inventorySlotKind = (rowObj["inventorySlotKind"] as JsonValue)!.GetValue<string>();
string? rarity = null;
if (rowObj["rarity"] is JsonValue rarityValue)
rarity = rarityValue.GetValue<string>();
string? bindPolicy = null;
if (rowObj["bindPolicy"] is JsonValue bindPolicyValue)
bindPolicy = bindPolicyValue.GetValue<string>();
int? durabilityMax = null;
if (rowObj["durabilityMax"] is JsonValue durabilityMaxValue &&
durabilityMaxValue.TryGetValue<int>(out var dmax))
{
durabilityMax = dmax;
}
return new ItemDefRow(id, displayName, prototypeRole, stackMax, inventorySlotKind, rarity, bindPolicy, durabilityMax);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, index, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} items[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Item catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,81 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Items;
/// <summary>
/// Prototype Slice 1 roster gate (NEO-50), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_SLICE1_ITEM_IDS</c> / <c>_prototype_slice1_item_gate</c>.
/// </summary>
public static class PrototypeSlice1ItemCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedItemIds = FrozenSet.ToFrozenSet(
[
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_ROLES</c>.</summary>
public static readonly FrozenSet<string> ExpectedPrototypeRoles = FrozenSet.ToFrozenSet(
["material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"],
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the Slice 1 contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetSlice1GateError(
IReadOnlyDictionary<string, string> itemIdToSourceFile,
IReadOnlyDictionary<string, string> idToRole,
IReadOnlyDictionary<string, string> idToSlotKind,
IReadOnlyDictionary<string, int> idToStackMax)
{
var ids = itemIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedItemIds))
{
return
"error: prototype Slice 1 expects exactly item ids " +
$"[{string.Join(", ", ExpectedItemIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}
var roles = idToRole.Values.ToHashSet(StringComparer.Ordinal);
if (!roles.SetEquals(ExpectedPrototypeRoles))
{
return
"error: prototype Slice 1 requires exactly one row per prototypeRole " +
$"[{string.Join(", ", ExpectedPrototypeRoles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}], " +
$"roles seen: [{string.Join(", ", roles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}]";
}
string? equipStubId = null;
foreach (var kv in idToRole)
{
if (kv.Value == "equip_stub")
{
equipStubId = kv.Key;
break;
}
}
if (equipStubId is not null)
{
if (!idToSlotKind.TryGetValue(equipStubId, out var slotKind) || slotKind != "equipment")
{
idToSlotKind.TryGetValue(equipStubId, out slotKind);
return
$"error: '{equipStubId}' (equip_stub) must use inventorySlotKind 'equipment', got '{slotKind}'";
}
if (!idToStackMax.TryGetValue(equipStubId, out var stackMax) || stackMax != 1)
{
idToStackMax.TryGetValue(equipStubId, out stackMax);
return $"error: '{equipStubId}' (equip_stub) must use stackMax 1, got {stackMax}";
}
}
return null;
}
}

View File

@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>
/// Read-only access to validated mastery tracks and perks loaded at startup (<see cref="MasteryCatalog"/>).
/// </summary>
public interface IMasteryCatalogRegistry
{
/// <summary>Attempts to resolve a track by <c>skillId</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetTrack(string? skillId, [NotNullWhen(true)] out MasteryTrackRow? track);
/// <summary>Attempts to resolve a perk by stable <c>id</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetPerk(string? perkId, [NotNullWhen(true)] out PerkDefRow? perk);
/// <summary>Every loaded track, ordered by <see cref="MasteryTrackRow.SkillId"/> (ordinal).</summary>
IReadOnlyList<MasteryTrackRow> GetTracksInSkillIdOrder();
/// <summary>Every loaded perk, ordered by <see cref="PerkDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<PerkDefRow> GetPerksInIdOrder();
}

View File

@ -0,0 +1,22 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Persisted mastery branch picks and unlocked perks per player (NEO-47).</summary>
public interface IPlayerPerkStateStore
{
PerkStateSnapshot GetSnapshot(string playerId);
/// <summary>Whether <paramref name="playerId"/> can receive perk state writes (mirrors skill progression store bucket rules).</summary>
bool CanWritePlayer(string playerId);
/// <summary>Fails when pick already exists for <c>(skillId, tierIndex)</c> or player cannot be written.</summary>
bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId);
/// <summary>Adds perks not already unlocked; fails when player cannot be written.</summary>
bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds);
/// <summary>Removes a branch pick (engine rollback when perk unlock fails after pick).</summary>
bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex);
/// <summary>Clears all branch picks and unlocked perks for the player (NEO-48 dev fixture API).</summary>
bool TryResetPerkState(string playerId);
}

View File

@ -0,0 +1,179 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Thread-safe in-memory perk state; seeds the configured dev player (NEO-47).</summary>
public sealed class InMemoryPlayerPerkStateStore(IOptions<GamePositionOptions> options) : IPlayerPerkStateStore
{
private readonly ConcurrentDictionary<string, PlayerPerkState> byPlayer = CreateInitialMap(options.Value);
private readonly ConcurrentDictionary<string, object> playerLocks = new(StringComparer.OrdinalIgnoreCase);
private static ConcurrentDictionary<string, PlayerPerkState> CreateInitialMap(GamePositionOptions o)
{
var id = NormalizePlayerId(o.DevPlayerId);
var map = new ConcurrentDictionary<string, PlayerPerkState>(StringComparer.OrdinalIgnoreCase);
map[id] = new PlayerPerkState();
return map;
}
/// <inheritdoc />
public bool CanWritePlayer(string playerId)
{
var key = NormalizePlayerId(playerId);
return key.Length > 0 && byPlayer.ContainsKey(key);
}
/// <inheritdoc />
public PerkStateSnapshot GetSnapshot(string playerId)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return PerkStateSnapshot.Empty;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
return inner.ToSnapshot();
}
}
/// <inheritdoc />
public bool TrySetBranchPick(string playerId, string skillId, int tierIndex, string branchId)
{
var key = NormalizePlayerId(playerId);
var sid = skillId.Trim();
var bid = branchId.Trim();
if (key.Length == 0 || sid.Length == 0 || bid.Length == 0 || tierIndex < 1
|| !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
if (inner.HasBranchPick(sid, tierIndex))
{
return false;
}
inner.SetBranchPick(sid, tierIndex, bid);
return true;
}
}
/// <inheritdoc />
public bool TryAddUnlockedPerks(string playerId, IEnumerable<string> perkIds)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
foreach (var raw in perkIds)
{
var pid = raw.Trim();
if (pid.Length == 0)
{
continue;
}
inner.AddUnlockedPerk(pid);
}
return true;
}
}
/// <inheritdoc />
public bool TryRemoveBranchPick(string playerId, string skillId, int tierIndex)
{
var key = NormalizePlayerId(playerId);
var sid = skillId.Trim();
if (key.Length == 0 || sid.Length == 0 || tierIndex < 1 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
return inner.RemoveBranchPick(sid, tierIndex);
}
}
/// <inheritdoc />
public bool TryResetPerkState(string playerId)
{
var key = NormalizePlayerId(playerId);
if (key.Length == 0 || !byPlayer.TryGetValue(key, out var inner))
{
return false;
}
lock (playerLocks.GetOrAdd(key, _ => new object()))
{
inner.Reset();
return true;
}
}
private static string NormalizePlayerId(string? playerId)
{
var t = playerId?.Trim();
if (string.IsNullOrEmpty(t))
{
return string.Empty;
}
return t.ToLowerInvariant();
}
private sealed class PlayerPerkState
{
private readonly Dictionary<string, Dictionary<int, string>> branchPicks = new(StringComparer.Ordinal);
private readonly HashSet<string> unlockedPerks = new(StringComparer.Ordinal);
public bool HasBranchPick(string skillId, int tierIndex) =>
branchPicks.TryGetValue(skillId, out var tiers) && tiers.ContainsKey(tierIndex);
public void SetBranchPick(string skillId, int tierIndex, string branchId)
{
if (!branchPicks.TryGetValue(skillId, out var tiers))
{
tiers = new Dictionary<int, string>();
branchPicks[skillId] = tiers;
}
tiers[tierIndex] = branchId;
}
public void AddUnlockedPerk(string perkId) => unlockedPerks.Add(perkId);
public bool RemoveBranchPick(string skillId, int tierIndex) =>
branchPicks.TryGetValue(skillId, out var tiers) && tiers.Remove(tierIndex);
public void Reset()
{
branchPicks.Clear();
unlockedPerks.Clear();
}
public PerkStateSnapshot ToSnapshot()
{
var bySkill = new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal);
foreach (var (skillId, tiers) in branchPicks)
{
bySkill[skillId] = new Dictionary<int, string>(tiers);
}
return new PerkStateSnapshot(bySkill, new HashSet<string>(unlockedPerks, StringComparer.Ordinal));
}
}
}

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>One branch row within a mastery tier (NEO-46).</summary>
public sealed record MasteryBranchRow(
string BranchId,
string? DisplayName,
IReadOnlyList<string> PerkIds);

View File

@ -0,0 +1,35 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>In-memory mastery catalog loaded at startup (NEO-46). Prefer <see cref="IMasteryCatalogRegistry"/> for lookups.</summary>
public sealed class MasteryCatalog
{
public MasteryCatalog(
string masteryDirectory,
IReadOnlyDictionary<string, MasteryTrackRow> tracksBySkillId,
IReadOnlyDictionary<string, PerkDefRow> perksById,
int catalogJsonFileCount)
{
MasteryDirectory = masteryDirectory;
TracksBySkillId = new ReadOnlyDictionary<string, MasteryTrackRow>(
new Dictionary<string, MasteryTrackRow>(tracksBySkillId, StringComparer.Ordinal));
PerksById = new ReadOnlyDictionary<string, PerkDefRow>(
new Dictionary<string, PerkDefRow>(perksById, StringComparer.Ordinal));
CatalogJsonFileCount = catalogJsonFileCount;
}
/// <summary>Absolute path to the directory enumerated for <c>*_mastery.json</c> catalogs.</summary>
public string MasteryDirectory { get; }
public IReadOnlyDictionary<string, MasteryTrackRow> TracksBySkillId { get; }
public IReadOnlyDictionary<string, PerkDefRow> PerksById { get; }
public int TrackCount => TracksBySkillId.Count;
public int PerkCount => PerksById.Count;
/// <summary>Number of <c>*_mastery.json</c> files under <see cref="MasteryDirectory"/>.</summary>
public int CatalogJsonFileCount { get; }
}

View File

@ -0,0 +1,361 @@
using System.Text;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Loads and validates <c>content/mastery/*_mastery.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-46).</summary>
public static class MasteryCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static MasteryCatalog Load(
string masteryDirectory,
string schemaPath,
IReadOnlySet<string> knownSkillIds,
ILogger logger)
{
masteryDirectory = Path.GetFullPath(masteryDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(masteryDirectory))
errors.Add($"error: missing directory {masteryDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(masteryDirectory, "*_mastery.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_mastery.json files under {masteryDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var globalPerkIds = new Dictionary<string, string>(StringComparer.Ordinal);
var globalReferencedPerkIds = new Dictionary<string, string>(StringComparer.Ordinal);
var allTrackSkillIds = new List<string>();
var perksById = new Dictionary<string, PerkDefRow>(StringComparer.Ordinal);
var tracksBySkillId = new Dictionary<string, MasteryTrackRow>(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
JsonNode? root;
try
{
root = JsonNode.Parse(File.ReadAllText(path));
}
catch (System.Text.Json.JsonException ex)
{
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
continue;
}
if (root is not JsonObject rootObj)
{
errors.Add($"error: {path}: expected JSON object at root");
continue;
}
var eval = schema.Evaluate(rootObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} (root): schema validation failed");
errors.AddRange(schemaMsgs);
continue;
}
var perksNode = rootObj["perks"];
var tracksNode = rootObj["tracks"];
if (perksNode is not JsonObject perksObj || tracksNode is not JsonArray tracksArray)
{
errors.Add($"error: {path}: expected top-level 'perks' object and 'tracks' array");
continue;
}
var referencedPerkIdsInFile = new HashSet<string>(StringComparer.Ordinal);
foreach (var (perkKey, perkNode) in perksObj)
{
if (perkNode is not JsonObject perkObj)
{
errors.Add($"error: {path}: perks[{perkKey}] must be an object");
continue;
}
var pid = (perkObj["id"] as JsonValue)?.GetValue<string>();
if (pid is null)
continue;
if (perkKey != pid)
{
errors.Add($"error: {path}: perks map key '{perkKey}' must match PerkDef.id '{pid}'");
}
if (globalPerkIds.TryGetValue(pid, out var prevPath))
{
errors.Add($"error: duplicate perk id '{pid}' in {prevPath} and {path}");
}
else
{
globalPerkIds[pid] = path;
var displayName = (perkObj["displayName"] as JsonValue)?.GetValue<string>() ?? string.Empty;
string? effectKind = null;
if (perkObj["effectKind"] is JsonValue ek)
effectKind = ek.GetValue<string>();
perksById[pid] = new PerkDefRow(pid, displayName, effectKind);
}
}
for (var ti = 0; ti < tracksArray.Count; ti++)
{
if (tracksArray[ti] is not JsonObject trackObj)
{
errors.Add($"error: {path}: tracks[{ti}] must be an object");
continue;
}
var skillId = (trackObj["skillId"] as JsonValue)?.GetValue<string>();
if (skillId is not null)
{
allTrackSkillIds.Add(skillId);
if (!knownSkillIds.Contains(skillId))
{
var known = string.Join(", ", knownSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"));
errors.Add(
$"error: {path}: tracks[{ti}].skillId '{skillId}' is not a known SkillDef id (known: [{known}])");
}
if (tracksBySkillId.ContainsKey(skillId))
{
errors.Add($"error: duplicate mastery track for skillId '{skillId}' in catalog");
}
}
var tiersNode = trackObj["tiers"];
if (tiersNode is not JsonArray tiersArray)
continue;
var prevRequiredLevel = 0;
HashSet<string>? prevTierBranchIds = null;
var tierIndexValues = new List<int>();
var parsedTiers = new List<MasteryTierRow>();
for (var tierI = 0; tierI < tiersArray.Count; tierI++)
{
if (tiersArray[tierI] is not JsonObject tierObj)
continue;
var tierIndex = (tierObj["tierIndex"] as JsonValue)?.GetValue<int>();
if (tierIndex is int idx)
tierIndexValues.Add(idx);
var requiredLevel = (tierObj["requiredLevel"] as JsonValue)?.GetValue<int>();
if (requiredLevel is int rl)
{
if (rl <= prevRequiredLevel)
{
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}].requiredLevel must be strictly greater than previous tier ({prevRequiredLevel})");
}
prevRequiredLevel = rl;
}
var branchesNode = tierObj["branches"];
if (branchesNode is not JsonArray branchesArray)
continue;
var tierBranchIds = new List<string>();
var parsedBranches = new List<MasteryBranchRow>();
for (var bi = 0; bi < branchesArray.Count; bi++)
{
if (branchesArray[bi] is not JsonObject branchObj)
continue;
var branchId = (branchObj["branchId"] as JsonValue)?.GetValue<string>();
if (branchId is not null)
{
if (tierBranchIds.Contains(branchId, StringComparer.Ordinal))
{
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}] duplicate branchId '{branchId}'");
}
tierBranchIds.Add(branchId);
}
string? branchDisplayName = null;
if (branchObj["displayName"] is JsonValue dn)
branchDisplayName = dn.GetValue<string>();
var perkIdsList = new List<string>();
if (branchObj["perkIds"] is JsonArray perkIdsArray)
{
foreach (var perkIdNode in perkIdsArray)
{
if (perkIdNode is not JsonValue pv)
continue;
var perkId = pv.GetValue<string>();
if (!perksObj.ContainsKey(perkId))
{
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}].branches[{bi}] references unknown perkId '{perkId}'");
}
if (globalReferencedPerkIds.TryGetValue(perkId, out var prevRef))
{
errors.Add($"error: duplicate perk id reference '{perkId}' in {prevRef} and {path}");
}
else
{
globalReferencedPerkIds[perkId] =
$"{path} tracks[{ti}].tiers[{tierI}].branches[{bi}]";
}
referencedPerkIdsInFile.Add(perkId);
perkIdsList.Add(perkId);
}
}
if (branchId is not null)
parsedBranches.Add(new MasteryBranchRow(branchId, branchDisplayName, perkIdsList));
}
var tierBranchSet = tierBranchIds.ToHashSet(StringComparer.Ordinal);
if (prevTierBranchIds is not null && !tierBranchSet.SetEquals(prevTierBranchIds))
{
var current = string.Join(", ", tierBranchSet.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"));
var previous = string.Join(", ", prevTierBranchIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"));
errors.Add(
$"error: {path}: tracks[{ti}].tiers[{tierI}] branchId set [{current}] must match previous tier [{previous}]");
}
if (tierBranchIds.Count > 0)
prevTierBranchIds = tierBranchSet;
if (tierIndex is int tIdx && requiredLevel is int req)
parsedTiers.Add(new MasteryTierRow(tIdx, req, parsedBranches));
}
var tierIndexError = TryGetTierIndexGateError(path, ti, tierIndexValues);
if (tierIndexError is not null)
errors.Add(tierIndexError);
if (skillId is not null && parsedTiers.Count > 0)
tracksBySkillId[skillId] = new MasteryTrackRow(skillId, parsedTiers);
}
foreach (var perkKey in perksObj.Select(p => p.Key))
{
if (!referencedPerkIdsInFile.Contains(perkKey))
{
errors.Add($"error: {path}: perks['{perkKey}'] is not referenced by any branch perkIds");
}
}
}
ThrowIfAny(errors);
var slice4 = PrototypeSlice4MasteryCatalogRules.TryGetSlice4GateError(allTrackSkillIds);
if (slice4 is not null)
{
errors.Add(slice4);
ThrowIfAny(errors);
}
logger.LogInformation(
"Loaded mastery catalog from {MasteryDirectory}: {TrackCount} track(s), {PerkCount} perk(s) across {CatalogFileCount} JSON catalog file(s).",
masteryDirectory,
tracksBySkillId.Count,
perksById.Count,
jsonFiles.Length);
return new MasteryCatalog(masteryDirectory, tracksBySkillId, perksById, jsonFiles.Length);
}
/// <summary>
/// Server-only gate: unique tierIndex values must be exactly 1..N (NEO-46 kickoff).
/// Stable <c>tierIndex</c> keys <see cref="PerkUnlockEngine"/> branch picks and path-auto tier evaluation.
/// </summary>
internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList<int> tierIndexValues)
{
if (tierIndexValues.Count == 0)
return null;
var seen = new HashSet<int>();
foreach (var value in tierIndexValues)
{
if (!seen.Add(value))
{
return $"error: {filePath}: tracks[{trackIndex}] duplicate tierIndex {value}";
}
}
var expected = Enumerable.Range(1, tierIndexValues.Count).ToHashSet();
if (!seen.SetEquals(expected))
{
var sorted = string.Join(", ", seen.Order().Select(v => v.ToString()));
return
$"error: {filePath}: tracks[{trackIndex}] tierIndex values must be unique and sequential 1..{tierIndexValues.Count}, got [{sorted}]";
}
return null;
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Mastery catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Resolves mastery catalog paths for local dev, tests, and container layouts (NEO-46).</summary>
public static class MasteryCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/mastery</c> directory.</summary>
public static string? TryDiscoverMasteryDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "mastery");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the mastery catalog directory.
/// Empty <paramref name="configuredMasteryDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveMasteryDirectory(string? configuredMasteryDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredMasteryDirectory))
{
var discovered = TryDiscoverMasteryDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:MasteryDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/mastery'). " +
"Set Content:MasteryDirectory in configuration or environment (e.g. Content__MasteryDirectory).");
}
var trimmed = configuredMasteryDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a mastery catalog document (Draft 2020-12).</summary>
public static string ResolveMasteryCatalogSchemaPath(
string masteryDirectory,
string? configuredSchemaPath,
string contentRootPath)
{
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
{
var trimmed = configuredSchemaPath.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
return Path.GetFullPath(Path.Combine(masteryDirectory, "..", "schemas", "mastery-catalog.schema.json"));
}
}

View File

@ -0,0 +1,37 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Adapter over <see cref="MasteryCatalog"/> (NEO-46).</summary>
public sealed class MasteryCatalogRegistry(MasteryCatalog catalog) : IMasteryCatalogRegistry
{
/// <inheritdoc />
public bool TryGetTrack(string? skillId, [NotNullWhen(true)] out MasteryTrackRow? track)
{
if (string.IsNullOrEmpty(skillId))
{
track = null;
return false;
}
return catalog.TracksBySkillId.TryGetValue(skillId, out track);
}
/// <inheritdoc />
public bool TryGetPerk(string? perkId, [NotNullWhen(true)] out PerkDefRow? perk)
{
if (string.IsNullOrEmpty(perkId))
{
perk = null;
return false;
}
return catalog.PerksById.TryGetValue(perkId, out perk);
}
public IReadOnlyList<MasteryTrackRow> GetTracksInSkillIdOrder() =>
catalog.TracksBySkillId.Values.OrderBy(t => t.SkillId, StringComparer.Ordinal).ToList();
public IReadOnlyList<PerkDefRow> GetPerksInIdOrder() =>
catalog.PerksById.Values.OrderBy(p => p.Id, StringComparer.Ordinal).ToList();
}

View File

@ -0,0 +1,42 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>DI registration for the fail-fast mastery catalog (NEO-46).</summary>
public static class MasteryCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="MasteryCatalog"/> and <see cref="IMasteryCatalogRegistry"/> as singletons.</summary>
public static IServiceCollection AddMasteryCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<MasteryCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Mastery.MasteryCatalog");
var masteryDir = MasteryCatalogPathResolution.ResolveMasteryDirectory(
opts.MasteryDirectory,
hostEnv.ContentRootPath);
var schemaPath = MasteryCatalogPathResolution.ResolveMasteryCatalogSchemaPath(
masteryDir,
opts.MasteryCatalogSchemaPath,
hostEnv.ContentRootPath);
var knownSkillIds = skillCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
return MasteryCatalogLoader.Load(masteryDir, schemaPath, knownSkillIds, logger);
});
services.AddSingleton<IMasteryCatalogRegistry>(sp =>
new MasteryCatalogRegistry(sp.GetRequiredService<MasteryCatalog>()));
return services;
}
}

View File

@ -0,0 +1,46 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Dev-only route to reset/set mastery fixture state for Bruno and manual QA (NEO-48).</summary>
public static class MasteryFixtureApi
{
public static WebApplication MapMasteryFixtureApi(this WebApplication app)
{
app.MapPost(
"/game/players/{id}/__dev/mastery-fixture",
(string id, MasteryFixtureRequest? body, IPositionStateStore positions,
IPlayerPerkStateStore perkStore, IPlayerSkillProgressionStore xpStore) =>
{
if (body is null || body.SchemaVersion != MasteryFixtureRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
if (!MasteryFixtureOperations.TryNormalizeSkillXp(body.SkillXp, out _))
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
if (!MasteryFixtureOperations.TryApply(trimmedId, body, perkStore, xpStore))
{
return Results.NotFound();
}
return Results.Json(
new MasteryFixtureResponse
{
Applied = true,
});
});
return app;
}
}

View File

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>POST body for <c>POST /game/players/{{id}}/__dev/mastery-fixture</c> (NEO-48 Bruno/manual QA).</summary>
public sealed class MasteryFixtureRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
/// <summary>When true, clears all branch picks and unlocked perks for the player.</summary>
[JsonPropertyName("resetPerkState")]
public bool ResetPerkState { get; init; }
/// <summary>Absolute XP totals to set per <c>skillId</c> (omitted skills unchanged).</summary>
[JsonPropertyName("skillXp")]
public Dictionary<string, int>? SkillXp { get; init; }
}
public sealed class MasteryFixtureResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("applied")]
public bool Applied { get; init; }
}

View File

@ -0,0 +1,70 @@
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Applies dev-only mastery fixture resets (NEO-48).</summary>
public static class MasteryFixtureOperations
{
/// <summary>Validates <paramref name="skillXp"/> entries; returns false when any key is blank or XP is negative.</summary>
public static bool TryNormalizeSkillXp(
IReadOnlyDictionary<string, int>? skillXp,
out Dictionary<string, int> normalized)
{
normalized = new Dictionary<string, int>(StringComparer.Ordinal);
if (skillXp is null || skillXp.Count == 0)
{
return true;
}
foreach (var (skillId, xp) in skillXp)
{
var sid = skillId.Trim();
if (sid.Length == 0 || xp < 0)
{
normalized.Clear();
return false;
}
normalized[sid] = xp;
}
return true;
}
/// <summary>Applies skill XP (batch, then perk reset). Rolls back XP when perk reset fails after XP writes.</summary>
public static bool TryApply(
string playerId,
MasteryFixtureRequest body,
IPlayerPerkStateStore perkStore,
IPlayerSkillProgressionStore xpStore)
{
if (!TryNormalizeSkillXp(body.SkillXp, out var skillXp))
{
return false;
}
var previousXp = new Dictionary<string, int>(xpStore.GetXpTotals(playerId), StringComparer.Ordinal);
if (skillXp.Count > 0 && !xpStore.TrySetSkillXpTotals(playerId, skillXp))
{
return false;
}
if (!body.ResetPerkState)
{
return true;
}
if (perkStore.TryResetPerkState(playerId))
{
return true;
}
if (skillXp.Count > 0)
{
_ = xpStore.TrySetSkillXpTotals(playerId, previousXp);
}
return false;
}
}

View File

@ -0,0 +1,7 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>One tier row within a <see cref="MasteryTrackRow"/> (NEO-46).</summary>
public sealed record MasteryTierRow(
int TierIndex,
int RequiredLevel,
IReadOnlyList<MasteryBranchRow> Branches);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>One validated <c>MasteryTrack</c> from <c>content/mastery/*.json</c> (NEO-46).</summary>
public sealed record MasteryTrackRow(string SkillId, IReadOnlyList<MasteryTierRow> Tiers);

View File

@ -0,0 +1,8 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Result of <see cref="PerkUnlockEngine.TrySelectBranch"/> (NEO-47).</summary>
public sealed record PerkBranchSelectOutcome(
bool Success,
string? ReasonCode,
IReadOnlyList<PerkUnlockEvent> Events,
PerkStateSnapshot Snapshot);

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>One validated <c>PerkDef</c> from <c>content/mastery/*.json</c> (NEO-46).</summary>
public sealed record PerkDefRow(string Id, string DisplayName, string? EffectKind);

View File

@ -0,0 +1,6 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Result of <see cref="PerkUnlockEngine.ReevaluateAfterLevelUp"/> (NEO-47).</summary>
public sealed record PerkReevaluationOutcome(
IReadOnlyList<PerkUnlockEvent> Events,
PerkStateSnapshot Snapshot);

View File

@ -0,0 +1,124 @@
using NeonSprawl.Server.Game.PositionState;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>
/// Maps <c>GET</c>/<c>POST /game/players/{{id}}/perk-state</c> — read snapshot and branch selection (NEO-48).
/// </summary>
public static class PerkStateApi
{
public static WebApplication MapPerkStateApi(this WebApplication app)
{
app.MapGet(
"/game/players/{id}/perk-state",
(string id, IPositionStateStore positions, IPlayerPerkStateStore perkStore) =>
{
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
return Results.Json(BuildSnapshot(trimmedId, perkStore));
});
app.MapPost(
"/game/players/{id}/perk-state",
(string id, PerkBranchSelectRequest? body, IPositionStateStore positions, PerkUnlockEngine perkUnlockEngine) =>
{
if (body is null || body.SchemaVersion != PerkBranchSelectRequest.CurrentSchemaVersion)
{
return Results.BadRequest();
}
var trimmedId = id.Trim();
if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _))
{
return Results.NotFound();
}
var outcome = perkUnlockEngine.TrySelectBranch(
trimmedId,
body.SkillId,
body.TierIndex,
body.BranchId);
var perkState = BuildSnapshot(trimmedId, outcome.Snapshot);
if (!outcome.Success)
{
return Results.Json(
new PerkBranchSelectResponse
{
Selected = false,
ReasonCode = outcome.ReasonCode,
PerkState = perkState,
UnlockedEvents = [],
});
}
return Results.Json(
new PerkBranchSelectResponse
{
Selected = true,
PerkState = perkState,
UnlockedEvents = MapEvents(outcome.Events),
});
});
return app;
}
internal static PerkStateSnapshotResponse BuildSnapshot(string playerId, IPlayerPerkStateStore store) =>
BuildSnapshot(playerId, store.GetSnapshot(playerId));
internal static PerkStateSnapshotResponse BuildSnapshot(string playerId, PerkStateSnapshot snapshot)
{
var branchPicks = new List<PerkBranchPickTrackJson>(snapshot.BranchPicksBySkillId.Count);
foreach (var (skillId, tiers) in snapshot.BranchPicksBySkillId.OrderBy(static kv => kv.Key, StringComparer.Ordinal))
{
var picks = tiers
.OrderBy(static kv => kv.Key)
.Select(static kv => new PerkBranchPickRowJson { TierIndex = kv.Key, BranchId = kv.Value })
.ToList();
branchPicks.Add(new PerkBranchPickTrackJson { SkillId = skillId, Picks = picks });
}
var unlocked = snapshot.UnlockedPerkIds.OrderBy(static id => id, StringComparer.Ordinal).ToList();
return new PerkStateSnapshotResponse
{
PlayerId = playerId,
BranchPicks = branchPicks,
UnlockedPerkIds = unlocked,
};
}
private static IReadOnlyList<PerkUnlockedEventJson> MapEvents(IReadOnlyList<PerkUnlockEvent> events)
{
if (events.Count == 0)
{
return [];
}
var mapped = new List<PerkUnlockedEventJson>(events.Count);
foreach (var e in events)
{
mapped.Add(
new PerkUnlockedEventJson
{
PerkId = e.PerkId,
SkillId = e.SkillId,
TierIndex = e.TierIndex,
BranchId = e.BranchId,
Source = e.Source switch
{
PerkUnlockSource.BranchPick => "branch_pick",
PerkUnlockSource.LevelUp => "level_up",
_ => throw new InvalidOperationException($"Unexpected perk unlock source: {e.Source}"),
},
});
}
return mapped;
}
}

View File

@ -0,0 +1,98 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>JSON body for <c>GET /game/players/{{id}}/perk-state</c> (NEO-48).</summary>
public sealed class PerkStateSnapshotResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("playerId")]
public required string PlayerId { get; init; }
/// <summary>Branch picks per skill. Consumers key by <see cref="PerkBranchPickTrackJson.SkillId"/> — array order is not part of the contract.</summary>
[JsonPropertyName("branchPicks")]
public required IReadOnlyList<PerkBranchPickTrackJson> BranchPicks { get; init; }
[JsonPropertyName("unlockedPerkIds")]
public required IReadOnlyList<string> UnlockedPerkIds { get; init; }
}
public sealed class PerkBranchPickTrackJson
{
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("picks")]
public required IReadOnlyList<PerkBranchPickRowJson> Picks { get; init; }
}
public sealed class PerkBranchPickRowJson
{
[JsonPropertyName("tierIndex")]
public int TierIndex { get; init; }
[JsonPropertyName("branchId")]
public required string BranchId { get; init; }
}
/// <summary>POST body for branch selection on <c>POST …/perk-state</c> (NEO-48).</summary>
public sealed class PerkBranchSelectRequest
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("tierIndex")]
public int TierIndex { get; init; }
[JsonPropertyName("branchId")]
public required string BranchId { get; init; }
}
/// <summary>POST responses for branch selection — success (<see cref="Selected"/>) or structured deny (<see cref="ReasonCode"/>).</summary>
public sealed class PerkBranchSelectResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
[JsonPropertyName("selected")]
public bool Selected { get; init; }
[JsonPropertyName("reasonCode")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ReasonCode { get; init; }
[JsonPropertyName("perkState")]
public required PerkStateSnapshotResponse PerkState { get; init; }
[JsonPropertyName("unlockedEvents")]
public required IReadOnlyList<PerkUnlockedEventJson> UnlockedEvents { get; init; }
}
public sealed class PerkUnlockedEventJson
{
[JsonPropertyName("perkId")]
public required string PerkId { get; init; }
[JsonPropertyName("skillId")]
public required string SkillId { get; init; }
[JsonPropertyName("tierIndex")]
public int TierIndex { get; init; }
[JsonPropertyName("branchId")]
public string? BranchId { get; init; }
[JsonPropertyName("source")]
public required string Source { get; init; }
}

View File

@ -0,0 +1,24 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Registers perk state persistence and unlock engine (NEO-47).</summary>
public static class PerkStateServiceCollectionExtensions
{
public static IServiceCollection AddPerkStateStore(this IServiceCollection services, IConfiguration configuration)
{
var cs = configuration.GetConnectionString(PositionStateServiceCollectionExtensions.NeonSprawlConnectionStringName);
if (!string.IsNullOrWhiteSpace(cs))
{
services.AddSingleton<IPlayerPerkStateStore, PostgresPlayerPerkStateStore>();
}
else
{
services.AddSingleton<IPlayerPerkStateStore, InMemoryPlayerPerkStateStore>();
}
services.AddSingleton<PerkUnlockEngine>();
return services;
}
}

View File

@ -0,0 +1,33 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>Read model for one player's perk state (NEO-47).</summary>
public sealed class PerkStateSnapshot
{
public static PerkStateSnapshot Empty { get; } = new(
new Dictionary<string, IReadOnlyDictionary<int, string>>(StringComparer.Ordinal),
new HashSet<string>(StringComparer.Ordinal));
public PerkStateSnapshot(
IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> branchPicksBySkillId,
IReadOnlySet<string> unlockedPerkIds)
{
BranchPicksBySkillId = branchPicksBySkillId;
UnlockedPerkIds = unlockedPerkIds;
}
/// <summary><c>skillId</c> → <c>tierIndex</c> → <c>branchId</c>.</summary>
public IReadOnlyDictionary<string, IReadOnlyDictionary<int, string>> BranchPicksBySkillId { get; }
public IReadOnlySet<string> UnlockedPerkIds { get; }
public bool TryGetBranchPick(string skillId, int tierIndex, out string branchId)
{
branchId = string.Empty;
if (!BranchPicksBySkillId.TryGetValue(skillId, out var tiers))
{
return false;
}
return tiers.TryGetValue(tierIndex, out branchId!);
}
}

View File

@ -0,0 +1,251 @@
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>
/// Server-authoritative perk unlock evaluation from skill level + branch picks (NEO-47).
/// Tier rule: all branches have empty <c>perkIds</c> ⇒ explicit pick tier; otherwise path-auto when a prior pick tier exists.
/// Keep in sync with <see cref="MasteryCatalogLoader"/> tier shape — see <see cref="TierRequiresExplicitPick"/>.
/// </summary>
public sealed class PerkUnlockEngine(
IMasteryCatalogRegistry catalog,
IPlayerPerkStateStore perkStore,
IPlayerSkillProgressionStore xpStore,
ISkillLevelCurve levelCurve)
{
/// <summary>Selects a branch for a pick tier; unlocks tier perks and retroactive path-auto tiers at current level.</summary>
public PerkBranchSelectOutcome TrySelectBranch(
string playerId,
string skillIdRaw,
int tierIndex,
string branchIdRaw)
{
var snapshot = perkStore.GetSnapshot(playerId);
var skillId = skillIdRaw.Trim();
var branchId = branchIdRaw.Trim();
if (skillId.Length == 0 || branchId.Length == 0)
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTrack);
}
if (!catalog.TryGetTrack(skillId, out var track))
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTrack);
}
var tier = FindTier(track, tierIndex);
if (tier is null)
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownTier);
}
if (TierIsPathAuto(tier))
{
return Deny(snapshot, PerkUnlockReasonCodes.TierBranchNotSelectable);
}
if (!TierContainsBranch(tier, branchId))
{
return Deny(snapshot, PerkUnlockReasonCodes.UnknownBranch);
}
if (!perkStore.CanWritePlayer(playerId))
{
return Deny(snapshot, PerkUnlockReasonCodes.PlayerNotFound);
}
var level = GetSkillLevel(playerId, track.SkillId);
if (level < tier.RequiredLevel)
{
return Deny(snapshot, PerkUnlockReasonCodes.LevelTooLow);
}
if (snapshot.TryGetBranchPick(track.SkillId, tier.TierIndex, out _))
{
return Deny(snapshot, PerkUnlockReasonCodes.BranchAlreadyChosen);
}
if (!perkStore.TrySetBranchPick(playerId, track.SkillId, tier.TierIndex, branchId))
{
return Deny(snapshot, PerkUnlockReasonCodes.PlayerNotFound);
}
var branch = tier.Branches.First(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
var allEvents = new List<PerkUnlockEvent>();
if (!TryUnlockPerks(
playerId,
track.SkillId,
tier.TierIndex,
branch.BranchId,
branch.PerkIds,
PerkUnlockSource.BranchPick,
snapshot,
out var pickEvents))
{
_ = perkStore.TryRemoveBranchPick(playerId, track.SkillId, tier.TierIndex);
return Deny(perkStore.GetSnapshot(playerId), PerkUnlockReasonCodes.PlayerNotFound);
}
allEvents.AddRange(pickEvents);
snapshot = perkStore.GetSnapshot(playerId);
allEvents.AddRange(ApplyPathAutoTiersAtOrBelowLevel(playerId, track, level, snapshot));
snapshot = perkStore.GetSnapshot(playerId);
return new PerkBranchSelectOutcome(true, null, allEvents, snapshot);
}
/// <summary>Unlocks path-auto tier perks after skill level-up (idempotent).</summary>
public PerkReevaluationOutcome ReevaluateAfterLevelUp(string playerId, string skillIdRaw, int newLevel)
{
var skillId = skillIdRaw.Trim();
if (skillId.Length == 0 || !catalog.TryGetTrack(skillId, out var track))
{
return new PerkReevaluationOutcome([], perkStore.GetSnapshot(playerId));
}
var snapshot = perkStore.GetSnapshot(playerId);
var events = ApplyPathAutoTiersAtOrBelowLevel(playerId, track, newLevel, snapshot);
return new PerkReevaluationOutcome(events, perkStore.GetSnapshot(playerId));
}
private PerkBranchSelectOutcome Deny(PerkStateSnapshot snapshot, string reason) =>
new(false, reason, [], snapshot);
private int GetSkillLevel(string playerId, string skillId)
{
var xpBySkill = xpStore.GetXpTotals(playerId);
xpBySkill.TryGetValue(skillId, out var xp);
return levelCurve.LevelFromTotalXp(xp);
}
private static MasteryTierRow? FindTier(MasteryTrackRow track, int tierIndex) =>
track.Tiers.FirstOrDefault(t => t.TierIndex == tierIndex);
/// <summary>True when every branch has no perks — player must pick a branch explicitly.</summary>
internal static bool TierRequiresExplicitPick(MasteryTierRow tier) =>
tier.Branches.All(static b => b.PerkIds.Count == 0);
internal static bool TierIsPathAuto(MasteryTierRow tier) => !TierRequiresExplicitPick(tier);
private static bool TierContainsBranch(MasteryTierRow tier, string branchId) =>
tier.Branches.Any(b => string.Equals(b.BranchId, branchId, StringComparison.Ordinal));
/// <summary>Branch id from the highest prior tier that required an explicit pick.</summary>
internal static string? GetPathBranchId(PerkStateSnapshot snapshot, MasteryTrackRow track, MasteryTierRow tier)
{
MasteryTierRow? anchor = null;
foreach (var prior in track.Tiers.OrderByDescending(static t => t.TierIndex))
{
if (prior.TierIndex >= tier.TierIndex)
{
continue;
}
if (TierRequiresExplicitPick(prior))
{
anchor = prior;
break;
}
}
if (anchor is null)
{
return null;
}
return snapshot.TryGetBranchPick(track.SkillId, anchor.TierIndex, out var bid) ? bid : null;
}
private List<PerkUnlockEvent> ApplyPathAutoTiersAtOrBelowLevel(
string playerId,
MasteryTrackRow track,
int maxLevel,
PerkStateSnapshot snapshot)
{
var allEvents = new List<PerkUnlockEvent>();
foreach (var tier in track.Tiers.OrderBy(static t => t.TierIndex))
{
if (tier.RequiredLevel > maxLevel || !TierIsPathAuto(tier))
{
continue;
}
var pathBranchId = GetPathBranchId(snapshot, track, tier);
if (pathBranchId is null)
{
continue;
}
var branch = tier.Branches.FirstOrDefault(b =>
string.Equals(b.BranchId, pathBranchId, StringComparison.Ordinal));
if (branch is null)
{
continue;
}
if (!TryUnlockPerks(
playerId,
track.SkillId,
tier.TierIndex,
branch.BranchId,
branch.PerkIds,
PerkUnlockSource.LevelUp,
snapshot,
out var batch))
{
continue;
}
allEvents.AddRange(batch);
snapshot = perkStore.GetSnapshot(playerId);
}
return allEvents;
}
private bool TryUnlockPerks(
string playerId,
string skillId,
int tierIndex,
string branchId,
IReadOnlyList<string> perkIds,
PerkUnlockSource source,
PerkStateSnapshot snapshot,
out IReadOnlyList<PerkUnlockEvent> events)
{
events = [];
if (perkIds.Count == 0)
{
return true;
}
var newIds = new List<string>();
foreach (var perkId in perkIds)
{
if (!snapshot.UnlockedPerkIds.Contains(perkId))
{
newIds.Add(perkId);
}
}
if (newIds.Count == 0)
{
return true;
}
if (!perkStore.TryAddUnlockedPerks(playerId, newIds))
{
return false;
}
events = newIds
.Select(pid => new PerkUnlockEvent(playerId, pid, skillId, tierIndex, branchId, source))
.ToList();
// --- Telemetry hook site (NEO-49): future E9.M1 catalog event `perk_unlock` ---
// TODO(E9.M1): catalog emit — once per entry in `events` (idempotent reevaluation produces none).
// Planned payload fields: playerId, perkId, skillId, tierIndex, branchId, source (BranchPick | LevelUp).
// No ingest or ILogger here (comments-only).
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More