Compare commits
57 Commits
d64c6baa0a
...
da0562bdf4
| Author | SHA1 | Date |
|---|---|---|
|
|
da0562bdf4 | |
|
|
38f0c56711 | |
|
|
1c9c93513e | |
|
|
c2366eed4c | |
|
|
0a3c28ae5c | |
|
|
d23f6dd8ab | |
|
|
2ba129dca1 | |
|
|
b461f3bf47 | |
|
|
b5674c4150 | |
|
|
37761a4a32 | |
|
|
afa39c48af | |
|
|
02b6b3a460 | |
|
|
0ce952131a | |
|
|
8fa828daa0 | |
|
|
bbc6b20fd3 | |
|
|
923ae0511c | |
|
|
6616bfbca5 | |
|
|
fa70e1bd6e | |
|
|
56233bfe2c | |
|
|
1ad04f05cd | |
|
|
00c5937872 | |
|
|
12e52bccc9 | |
|
|
aa83790703 | |
|
|
e9c02eded7 | |
|
|
aba26446d3 | |
|
|
2bb02d76e9 | |
|
|
ffdbed26f2 | |
|
|
b72201b1f0 | |
|
|
f4c3fe86e4 | |
|
|
d5b65c94f6 | |
|
|
3ba8300652 | |
|
|
0838e661c9 | |
|
|
39c54c3881 | |
|
|
5290044845 | |
|
|
1c102af15e | |
|
|
5058bc1963 | |
|
|
7ce9dc172c | |
|
|
38ef54c238 | |
|
|
340dbbe60d | |
|
|
0d63965724 | |
|
|
f3e48f4077 | |
|
|
01a02da923 | |
|
|
76e710b491 | |
|
|
0a2a6fe45b | |
|
|
2520a80b6e | |
|
|
38774a5b39 | |
|
|
e297b3a023 | |
|
|
619146da85 | |
|
|
28bafaf77d | |
|
|
07fc566e2c | |
|
|
174c390aa4 | |
|
|
898f935b4f | |
|
|
4ede4798d9 | |
|
|
594287c199 | |
|
|
4a9730c577 | |
|
|
1f6fe86a1c | |
|
|
f5f04d8258 |
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"stop": [
|
||||
{
|
||||
"command": ".cursor/hooks/check-story-branch-clean.sh",
|
||||
"loop_limit": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 **agent’s 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: item-catalog
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: mastery-catalog
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 3–4) before happy-path POST tier1 if you need a clean dev-local-1; deny pre-requests reset shared player state.
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"perks": {
|
||||
"salvage_scrap_efficiency_1": {
|
||||
"id": "salvage_scrap_efficiency_1",
|
||||
"displayName": "Scrap Sifter",
|
||||
"effectKind": "gather_yield_modifier"
|
||||
},
|
||||
"salvage_bulk_haul_1": {
|
||||
"id": "salvage_bulk_haul_1",
|
||||
"displayName": "Haul Frame",
|
||||
"effectKind": "gather_carry_modifier"
|
||||
}
|
||||
},
|
||||
"tracks": [
|
||||
{
|
||||
"skillId": "salvage",
|
||||
"tiers": [
|
||||
{
|
||||
"tierIndex": 1,
|
||||
"requiredLevel": 2,
|
||||
"branches": [
|
||||
{
|
||||
"branchId": "scrap_efficiency",
|
||||
"displayName": "Scrap Efficiency",
|
||||
"perkIds": []
|
||||
},
|
||||
{
|
||||
"branchId": "bulk_haul",
|
||||
"displayName": "Bulk Haul",
|
||||
"perkIds": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tierIndex": 2,
|
||||
"requiredLevel": 4,
|
||||
"branches": [
|
||||
{
|
||||
"branchId": "scrap_efficiency",
|
||||
"displayName": "Scrap Efficiency",
|
||||
"perkIds": ["salvage_scrap_efficiency_1"]
|
||||
},
|
||||
{
|
||||
"branchId": "bulk_haul",
|
||||
"displayName": "Bulk Haul",
|
||||
"perkIds": ["salvage_bulk_haul_1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://neon-sprawl.local/schemas/mastery-catalog.schema.json",
|
||||
"title": "MasteryCatalog",
|
||||
"description": "Mastery tracks and perk definitions for content/mastery/*_mastery.json. Perk and branch ids are stable forever—rename displayName only. See docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "perks", "tracks"],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"perks": {
|
||||
"type": "object",
|
||||
"description": "Map of perkId to PerkDef. Keys must match each PerkDef.id.",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/PerkDef"
|
||||
}
|
||||
},
|
||||
"tracks": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/MasteryTrack"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"stableId": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"PerkDef": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "displayName"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stableId",
|
||||
"description": "Immutable perk key for unlock state and telemetry."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Player-facing label; safe to change without migrating unlock data."
|
||||
},
|
||||
"effectKind": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional stub for future gameplay hooks (E3+). No apply in E2.M3 core."
|
||||
}
|
||||
}
|
||||
},
|
||||
"MasteryBranch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["branchId", "perkIds"],
|
||||
"properties": {
|
||||
"branchId": {
|
||||
"$ref": "#/$defs/stableId",
|
||||
"description": "Immutable branch key; mutually exclusive pick per tier."
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"perkIds": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"$ref": "#/$defs/stableId"
|
||||
},
|
||||
"description": "Perks unlocked when this branch is chosen at this tier (may be empty at tier 1)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"MasteryTier": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["tierIndex", "requiredLevel", "branches"],
|
||||
"properties": {
|
||||
"tierIndex": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "1-based tier index; stable key for PerkState branch picks (NEO-47+)."
|
||||
},
|
||||
"requiredLevel": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Skill level from E2.M2 required before this tier's branches are eligible."
|
||||
},
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/MasteryBranch"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MasteryTrack": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["skillId", "tiers"],
|
||||
"properties": {
|
||||
"skillId": {
|
||||
"$ref": "#/$defs/stableId",
|
||||
"description": "Must exist in E2.M1 SkillDef catalog."
|
||||
},
|
||||
"tiers": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/MasteryTier"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,9 +37,10 @@ Provide a data-driven **non-combat skill** layer: `SkillDef` registry, **skill**
|
|||
### E2.M3 - MasteryAndPerkUnlocks
|
||||
|
||||
- Responsibility: Mastery tracks and perk unlock state for **skill** build expression beyond raw levels.
|
||||
- Key contracts: `MasteryTrack`, `PerkUnlockEvent`, `PerkState`
|
||||
- Dependencies: E2.M2
|
||||
- Key contracts: `MasteryTrack`, `PerkDef`, `PerkUnlockEvent`, `PerkState`
|
||||
- Dependencies: E2.M2, E2.M1 (catalog `skillId` validation)
|
||||
- Stage target: Pre-production
|
||||
- **Linear (Slice 4 — E2.M3):** [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) — [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) (label **`E2.M3`**); flagship **`salvage`** track; table and ordering in [E2.M3 — MasteryAndPerkUnlocks](../modules/E2_M3_MasteryAndPerkUnlocks.md) and [E2M3-pre-production-backlog.md](../../plans/E2M3-pre-production-backlog.md).
|
||||
|
||||
### E2.M4 - ProgressionPacingControls
|
||||
|
||||
|
|
@ -80,8 +81,9 @@ Provide a data-driven **non-combat skill** layer: `SkillDef` registry, **skill**
|
|||
|
||||
### Slice 4 - Mastery and pacing (pre-production)
|
||||
|
||||
- Scope: E2.M3 and E2.M4 for first mastery branch and global pacing policy.
|
||||
- Dependencies: E2.M2, E9.M2
|
||||
- **Linear (E2.M3):** [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49) — see [E2.M3 module](../modules/E2_M3_MasteryAndPerkUnlocks.md). **E2.M4** pacing backlog is separate (depends on E9.M2).
|
||||
- Scope: E2.M3 for first **salvage** mastery branch; E2.M4 for global pacing policy when E9.M2 is available.
|
||||
- Dependencies: E2.M2 (E2.M3); E2.M2 + E9.M2 (E2.M4)
|
||||
- Acceptance criteria:
|
||||
- At least one mastery path unlocks a perk without blocking other skills.
|
||||
- Pacing policy can be toggled for test cohorts without redeploy where possible.
|
||||
|
|
|
|||
|
|
@ -41,7 +41,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@
|
|||
|
||||
**NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** + **`RefineSkillXpConstants`** + test **`RefineActivityDeniedRegistryWebApplicationFactory`** — craft/refine success is intended to award **`refine`** XP (**`sourceKind: activity`**, **10** XP) via the same **`SkillProgressionGrantOperations`** stack as NEO-38 ([implementation plan](../../plans/NEO-42-implementation-plan.md)); manual QA **[`NEO-42.md`](../../manual-qa/NEO-42.md)**; **[server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42)**. **E3.M2** must invoke **`GrantOnSuccessfulCraftOrRefine`** on real **`CraftResult`** success for end-to-end behavior (no craft route in repo at this slice).
|
||||
|
||||
**Still backlog within this module:** Slice 3 **NEO-43** (and **NEO-44** gig path); **NEO-42** end-to-end wiring awaits **E3.M2** success handler. Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as Slice 2 stories merge.
|
||||
**NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** for scripted **`mission_reward`** skill XP ([implementation plan](../../plans/NEO-43-implementation-plan.md)); manual QA **[`NEO-43.md`](../../manual-qa/NEO-43.md)**; full quest hand-in awaits **E7.M2**.
|
||||
|
||||
**Still backlog within E2.M2 / Slice 3:** **NEO-44** (gig XP from combat, Epic 5 — not skill XP via E2.M2). **NEO-42** end-to-end craft wiring awaits **E3.M2** success handler.
|
||||
|
||||
**E2.M3:** [MasteryAndPerkUnlocks](E2_M3_MasteryAndPerkUnlocks.md) re-evaluates perk eligibility on skill **level-up** after grants ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) → [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)).
|
||||
|
||||
Keep this paragraph and [documentation tracking](documentation_and_implementation_alignment.md) in sync as stories merge.
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
|
|||
|
|
@ -6,38 +6,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 player’s **skill level** reaches **`requiredLevel`**, that tier’s branches become **eligible** for selection (server-authoritative).
|
||||
- **One branch per tier:** picking `scrap_efficiency` forecloses `bulk_haul` for that tier; perks on the chosen branch unlock; the other branch’s perks for that tier do **not** unlock.
|
||||
- **Other skills** (`refine`, `intrusion`) have **no** mastery rows in the prototype catalog until a deliberate expansion issue relaxes CI.
|
||||
- **Respec** is not implemented in v1 — document in content if a perk is “permanent for the character.”
|
||||
- **`displayName`** on a branch row is optional in schema; tier 1 and tier 2 may both include it for consistent authoring.
|
||||
|
||||
## NEO-46 handoff (server catalog load)
|
||||
|
||||
**Landed ([NEO-46](https://linear.app/neon-sprawl/issue/NEO-46)):** Server boot loads `content/mastery/*_mastery.json` fail-fast under `Game/Mastery/`, mirroring Python CI gates from [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries) plus cross-check against `ISkillDefinitionRegistry`. **`tierIndex`** uniqueness and sequential **1..N** per track are enforced at server boot and in **`validate_content.py`** (protects NEO-47 `PerkState` keys). Read-only **`IMasteryCatalogRegistry`** is registered for NEO-47+.
|
||||
|
||||
## NEO-47 handoff (perk unlock engine + PerkState persistence)
|
||||
|
||||
**Landed ([NEO-47](https://linear.app/neon-sprawl/issue/NEO-47)):** `IPlayerPerkStateStore` (in-memory + Postgres **`V004`**) persists branch picks and unlocked `perkId`s. **`PerkUnlockEngine`** evaluates tier-1 explicit branch picks and **path-auto** tier-2 unlocks from the tier-1 `branchId` when skill level meets gates (no second pick at tier 2 for prototype salvage). Level-up re-evaluation runs inside **`SkillProgressionGrantOperations`** after XP grants (HTTP POST, gather interact, mission/refine helpers). Stable deny **`reasonCode`** values for invalid picks. Internal **`PerkUnlockEvent`** returned from engine methods.
|
||||
|
||||
## NEO-48 handoff (perk state HTTP + Bruno)
|
||||
|
||||
**Landed ([NEO-48](https://linear.app/neon-sprawl/issue/NEO-48)):** Versioned **`GET`/`POST /game/players/{id}/perk-state`** — read model + branch selection via **`PerkUnlockEngine`**; structured **`selected`** / **`reasonCode`** / **`unlockedEvents`** envelopes. Bruno: `bruno/neon-sprawl-server/perk-state/`; [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); plan [NEO-48](../../plans/NEO-48-implementation-plan.md); manual QA [`NEO-48.md`](../../manual-qa/NEO-48.md).
|
||||
|
||||
## NEO-49 handoff (perk_unlock telemetry hook sites)
|
||||
|
||||
**Landed ([NEO-49](https://linear.app/neon-sprawl/issue/NEO-49)):** Comment-only **`perk_unlock`** hook site in **`PerkUnlockEngine.TryUnlockPerks`** (authoritative unlock anchor; covers branch-pick and level-up path-auto). **`TODO(E9.M1)`**; no production ingest. [server README — Perk unlock engine and telemetry hooks](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49); plan [NEO-49](../../plans/NEO-49-implementation-plan.md); manual QA [`NEO-49.md`](../../manual-qa/NEO-49.md).
|
||||
|
||||
## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))
|
||||
|
||||
The **first shipped mastery track** is **`salvage`** only:
|
||||
|
||||
| Tier | `requiredLevel` | Branches (pick one) | Tier-2 `perkId` (chosen branch only) |
|
||||
|------|-----------------|---------------------|--------------------------------------|
|
||||
| 1 | 2 | `scrap_efficiency` · `bulk_haul` | *(none — branch pick only)* |
|
||||
| 2 | 4 | same branch ids | `salvage_scrap_efficiency_1` · `salvage_bulk_haul_1` |
|
||||
|
||||
**Rules:** Do **not** rename `branchId` / `perkId` values to “fix” wording—change `displayName` only, or add new ids and migrate content. Expanding mastery to **`refine`** / **`intrusion`** belongs in a new Linear issue once the catalog gate intentionally grows.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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-37–NEO-41, NEO-42–NEO-44 |
|
||||
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ Names below are **v1** after a theme pass; abilities and lore can still rename a
|
|||
**Open:**
|
||||
|
||||
- **Curves:** same philosophy as **skills** (soft feel, more XP per level)—per-gig tables vs global template **TBD**.
|
||||
- **Mastery/perks** on gigs vs flat level gates—**open** (E2.M3-style expression).
|
||||
- **Mastery/perks** on gigs vs flat level gates—**open** (branch/perk depth **analogous** to [E2.M3 skill mastery](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md), **not** the E2.M3 module itself—gig trees stay a **separate** system per [skills.md](skills.md)).
|
||||
|
||||
## Hub swap
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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`.
|
||||
|
|
@ -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).
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
# E2.M3 — Pre-production story backlog (MasteryAndPerkUnlocks)
|
||||
|
||||
Working backlog for **Epic 2 — Slice 4** ([mastery and pacing](../decomposition/epics/epic_02_skills_and_progression.md#slice-4---mastery-and-pacing-pre-production)). Decomposition and contracts: [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md).
|
||||
|
||||
**Labels (Linear):** every issue **`E2.M3`**; add **`server`** / **`Feature`** as listed per issue.
|
||||
|
||||
**Precursor (do not re-scope):** skill **level** authority lives in **E2.M2** ([E2.M2 module](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md)); `SkillDef` ids from **E2.M1**. **Gig** perks are **not** `SkillDef` mastery.
|
||||
|
||||
**Flagship skill:** **`salvage`** (gather loop — [NEO-41](https://linear.app/neon-sprawl/issue/NEO-41)).
|
||||
|
||||
**Linear issues (created):** attach `docs/plans/NEO-*-implementation-plan.md` on the same branch as implementation work.
|
||||
|
||||
| Slug | Linear |
|
||||
|------|--------|
|
||||
| E2M3-01 | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45/e2m3-lock-prototype-salvage-mastery-catalog-schemas-ci) |
|
||||
| E2M3-02 | [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast) |
|
||||
| E2M3-03 | [NEO-47](https://linear.app/neon-sprawl/issue/NEO-47/e2m3-perk-unlock-engine-perkstate-persistence) |
|
||||
| E2M3-04 | [NEO-48](https://linear.app/neon-sprawl/issue/NEO-48/e2m3-perk-state-get-branch-selection-post-bruno) |
|
||||
| E2M3-05 | [NEO-49](https://linear.app/neon-sprawl/issue/NEO-49/e2m3-perk-unlock-telemetry-hook-sites) |
|
||||
|
||||
**Dependency graph in Linear:** E2M3-02 blocked by E2M3-01. E2M3-03 blocked by E2M3-02 and [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38). E2M3-04 blocked by E2M3-03. E2M3-05 blocked by E2M3-03 (may parallel E2M3-04).
|
||||
|
||||
---
|
||||
|
||||
## Story order (recommended)
|
||||
|
||||
| Order | Slug | Depends on |
|
||||
|-------|------|------------|
|
||||
| 1 | **E2M3-01** | E2.M1 `salvage` `SkillDef` ([NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) |
|
||||
| 2 | **E2M3-02** | E2M3-01 |
|
||||
| 3 | **E2M3-03** | E2M3-02, [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) (skill level) |
|
||||
| 4 | **E2M3-04** | E2M3-03 |
|
||||
| 5 | **E2M3-05** | E2M3-03 (parallel OK with E2M3-04) |
|
||||
|
||||
---
|
||||
|
||||
### E2M3-01 — Prototype salvage mastery catalog + schemas + CI
|
||||
|
||||
**Goal:** Lock content shape and CI validation for one **`salvage`** `MasteryTrack` before any server load.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `content/schemas/mastery-catalog.schema.json` (or equivalent single-catalog schema).
|
||||
- `content/mastery/prototype_salvage_mastery.json` with tier 1 @ level 2 (`scrap_efficiency` vs `bulk_haul`) and tier 2 @ level 4 perks.
|
||||
- `scripts/validate_content.py` checks: unknown `skillId`, duplicate perk ids, branch integrity.
|
||||
- Module doc designer note (mutually exclusive branches; no respec v1).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Server loader, perk state, HTTP, client HUD.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [x] PR gate validates mastery JSON against schema. (**Done** — NEO-45.)
|
||||
- [x] Only **`salvage`** has a track; `refine` / `intrusion` not required. (**Done** — NEO-45.)
|
||||
- [x] Stable `branchId` / `perkId` documented in [E2_M3](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) freeze box. (**Done** — NEO-45.)
|
||||
|
||||
---
|
||||
|
||||
### E2M3-02 — Server mastery catalog load (fail-fast)
|
||||
|
||||
**Goal:** Disk → host: startup load of mastery catalog(s) with cross-check against `ISkillDefinitionRegistry`.
|
||||
|
||||
**NEO-46 kickoff:** mirror CI gate constants from [NEO-45 implementation plan](NEO-45-implementation-plan.md) (see E2.M3 **NEO-46 handoff**).
|
||||
|
||||
**In scope**
|
||||
|
||||
- Loader + `IMasteryCatalogRegistry` under `server/NeonSprawl.Server/Game/Skills/` (or `Game/Mastery/` if split).
|
||||
- Fail-fast on malformed files, duplicate ids, unknown `skillId`.
|
||||
- Unit tests (AAA) for loader happy path and failure modes.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Per-player state, unlock logic, HTTP.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Server refuses boot when catalog invalid (mirror E2.M1 catalog behavior).
|
||||
- [ ] Registry resolves track by `skillId` and perk metadata by `perkId`.
|
||||
|
||||
---
|
||||
|
||||
### E2M3-03 — Perk unlock engine + PerkState persistence
|
||||
|
||||
**Goal:** Server-authoritative unlock evaluation from **skill level** + branch selection; persist `PerkState`; re-run on level-up.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `IPlayerPerkStateStore` (in-memory + Postgres + migration), mirroring skill progression stores.
|
||||
- Unlock engine: tier eligibility, mutually exclusive branch pick, `PerkUnlockEvent` emission.
|
||||
- Hook after `SkillProgressionGrantOperations` (or dedicated service) on level-up for affected `skillId`.
|
||||
- Integration/unit tests (AAA): eligibility, select branch, deny invalid pick.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- HTTP API, Bruno, client UI.
|
||||
- Applying `effectKind` in gather/craft/combat.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Player at salvage level 2 can select one tier-1 branch; other branch’s tier-1 perks stay locked.
|
||||
- [ ] Level 4 unlocks tier-2 perks on the chosen branch path only.
|
||||
- [ ] Stable `reasonCode` on deny (unknown tier, branch already chosen, level too low, etc.).
|
||||
|
||||
---
|
||||
|
||||
### E2M3-04 — Perk state HTTP + Bruno
|
||||
|
||||
**Goal:** Versioned **GET** snapshot and **POST** branch selection for manual QA and future client.
|
||||
|
||||
**In scope**
|
||||
|
||||
- `GET /game/players/{id}/perk-state` (path finalized in implementation plan).
|
||||
- `POST` branch selection endpoint with structured success/deny payloads.
|
||||
- Bruno `bruno/neon-sprawl-server/perk-state/`.
|
||||
- `server/README.md` section; API tests (AAA).
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Godot HUD (optional future issue).
|
||||
- Gameplay stat application.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] GET returns unlocked perks + branch picks for prototype salvage track.
|
||||
- [ ] POST select branch persists and matches engine rules from E2M3-03.
|
||||
- [ ] Bruno exercises happy path + at least one deny case.
|
||||
|
||||
---
|
||||
|
||||
### E2M3-05 — `perk_unlock` telemetry hook sites
|
||||
|
||||
**Goal:** Comment-only hooks in unlock / branch-select path for future E9.M1 catalog event **`perk_unlock`**.
|
||||
|
||||
**In scope**
|
||||
|
||||
- Hook placement in unlock engine (and POST path if applicable).
|
||||
- `TODO(E9.M1)` comments; no production logging.
|
||||
- README + module doc pointer.
|
||||
|
||||
**Out of scope**
|
||||
|
||||
- Telemetry ingest, dashboards.
|
||||
|
||||
**Acceptance criteria**
|
||||
|
||||
- [ ] Hook sites documented in code and `server/README.md`.
|
||||
- [ ] Matches Epic 2 Slice 4 telemetry vocabulary.
|
||||
|
||||
---
|
||||
|
||||
## After this backlog
|
||||
|
||||
- Track delivery in Linear; keep `blockedBy` links synchronized if scope changes.
|
||||
- For each issue kickoff, add `docs/plans/{NEO-XX}-implementation-plan.md` per [story-kickoff](../../.cursor/rules/story-kickoff.md).
|
||||
- Add `docs/manual-qa/{NEO-XX}.md` when HTTP surfaces land (E2M3-04+).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [E2_M3_MasteryAndPerkUnlocks.md](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md)
|
||||
- [E2_M2_XpAwardAndLevelEngine.md](../decomposition/modules/E2_M2_XpAwardAndLevelEngine.md)
|
||||
- [E2_M1_SkillDefinitionRegistry.md](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md)
|
||||
- [epic_02_skills_and_progression.md](../decomposition/epics/epic_02_skills_and_progression.md)
|
||||
- [skills.md](../game-design/skills.md)
|
||||
|
|
@ -0,0 +1,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)
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
# NEO-45 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-45 |
|
||||
| **Title** | E2.M3: Lock prototype salvage mastery catalog + schemas + CI |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-45/e2m3-lock-prototype-salvage-mastery-catalog-schemas-ci |
|
||||
| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 |
|
||||
| **Branch** | `NEO-45-salvage-mastery-catalog-schemas-ci` |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Answer |
|
||||
|--------|----------|--------|
|
||||
| **PerkDef layout** | Top-level `perks` map vs inline on branches? | **Top-level `perks` map** — tracks reference `perkId` strings (dedupes metadata; mirrors skill catalog style). |
|
||||
| **Tier 1 unlocks** | Perks on branch pick at level 2? | **Branch pick only** — tier-1 branches have **empty `perkIds`**; first perks unlock at tier 2 (`requiredLevel` 4). |
|
||||
| **Frozen `perkId` strings** | Who names tier-2 perks? | **Agent proposal** (below); change `displayName` only after ship. |
|
||||
| **WIP decomposition docs** | Include uncommitted E2.M3 doc edits on this branch? | **Yes** — commit with kickoff/plan work. |
|
||||
| **NEO-33 dependency** | Blocker status? | **Satisfied** — `salvage` / `refine` / `intrusion` frozen in [`content/skills/prototype_skills.json`](../../content/skills/prototype_skills.json) (NEO-33 Done). |
|
||||
|
||||
### Frozen prototype ids (Slice 4 — salvage flagship)
|
||||
|
||||
| Kind | Value | Notes |
|
||||
|------|--------|--------|
|
||||
| `skillId` | `salvage` | Only skill with a `MasteryTrack` in prototype catalog. |
|
||||
| Tier 1 `branchId` | `scrap_efficiency`, `bulk_haul` | Mutually exclusive pick @ `requiredLevel` **2**; **no** perks yet. |
|
||||
| Tier 2 `branchId` | same ids | Continuation paths @ `requiredLevel` **4**; one perk each. |
|
||||
| Tier 2 `perkId` | `salvage_scrap_efficiency_1`, `salvage_bulk_haul_1` | **Immutable** after ship; tune player text via `displayName`. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Lock the **salvage** flagship mastery catalog under `content/mastery/` with JSON Schema and CI validation (`scripts/validate_content.py`) so tiers, branches, and perk ids are stable before server load (NEO-46).
|
||||
|
||||
**In scope (from Linear):**
|
||||
|
||||
- `content/schemas/mastery-catalog.schema.json` validates the prototype catalog shape.
|
||||
- `content/mastery/prototype_salvage_mastery.json` — one **`MasteryTrack`** for **`salvage`** (tier 1 @ level 2 branches; tier 2 @ level 4 perks).
|
||||
- `scripts/validate_content.py` — unknown `skillId`, duplicate `perkId`, branch/perk reference integrity.
|
||||
- Designer note: mutually exclusive branch picks per tier; no respec in v1 (E2.M3 module + `content/README.md`).
|
||||
- Include WIP E2.M3 decomposition / backlog doc updates on this branch.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server boot load (NEO-46).
|
||||
- Perk unlock engine, HTTP, client HUD (NEO-47+).
|
||||
- Separate mastery XP bar; gig perks; respec.
|
||||
- `refine` / `intrusion` mastery rows (CI must **not** require them).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] `content/schemas/mastery-catalog.schema.json` (or equivalent) validates prototype catalog.
|
||||
- [x] `content/mastery/prototype_salvage_mastery.json` ships one **MasteryTrack** for `salvage` only (tier 1 @ level 2: `scrap_efficiency` vs `bulk_haul`; tier 2 @ level 4 with one perk per branch path).
|
||||
- [x] CI fails on unknown `skillId`, duplicate perk ids, or invalid branch references.
|
||||
- [x] Designer note in module doc: mutually exclusive branch picks per tier; no respec in v1.
|
||||
- [x] `refine` / `intrusion` have **no** mastery rows yet (CI does not require them).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Schema (`mastery-catalog.schema.json`):** Draft 2020-12 object with `schemaVersion: 1`, top-level **`perks`** map (keys = perk `id`), and **`tracks`** array. Each track: `skillId`, ordered **`tiers`** (`tierIndex`, `requiredLevel`, `branches`). Each branch: `branchId`, optional `displayName`, `perkIds` (array, may be empty). Each perk: `id`, `displayName`, optional `effectKind` stub string. Enforce `snake_case` patterns for ids; `additionalProperties: false` on objects.
|
||||
2. **Prototype catalog:** Single file `content/mastery/prototype_salvage_mastery.json` with exactly **one** track (`salvage`), **two** tiers, **two** branches per tier (same `branchId` values tier-to-tier), **two** perks in `perks` map referenced only from tier 2. Stub `effectKind` values (e.g. `gather_yield_modifier`) for future E3 wiring — no gameplay apply.
|
||||
3. **`validate_content.py`:** After existing skill + level-curve validation, load skill ids from validated `*_skills.json` files, then validate `content/mastery/*_mastery.json` against mastery schema. Post-schema gates:
|
||||
- **Unknown `skillId`:** track `skillId` ∉ loaded skill ids.
|
||||
- **Duplicate `perkId`:** same id in `perks` map or referenced twice across branches.
|
||||
- **Branch integrity:** every `perkIds[]` entry exists in `perks`; tier 2 `branchId` set equals tier 1 `branchId` set; `requiredLevel` strictly increases by tier; unique `branchId` per tier.
|
||||
- **Slice 4 gate:** exactly **one** track; its `skillId` is **`salvage`** only (no `refine` / `intrusion` tracks).
|
||||
4. **Docs:** Extend `content/README.md` with `mastery/` paths and freeze reminder. Ensure E2.M3 module freeze box lists concrete `perkId` values. Update CT.M1 CI prototype paragraph. Refresh `documentation_and_implementation_alignment.md` E2.M3 row. Commit included WIP decomposition/backlog edits.
|
||||
5. **No server/C#/client changes** in this story.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `content/schemas/mastery-catalog.schema.json` | JSON Schema for mastery catalog files (`perks` + `tracks`). |
|
||||
| `content/mastery/prototype_salvage_mastery.json` | Prototype **salvage** `MasteryTrack`, tier/branch tree, and perk definitions. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `scripts/validate_content.py` | Validate mastery catalogs; cross-check `skillId`; duplicate `perkId`; branch/perk integrity; Slice 4 salvage-only gate. |
|
||||
| `content/README.md` | Document `content/mastery/`, schema path, Slice 4 freeze, local validate command. |
|
||||
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | Confirm designer note + freeze table with concrete `perkId` values (WIP edits). |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | PR gate paragraph: mastery catalog validation. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 / NEO-45 row after catalog lands. |
|
||||
| `docs/decomposition/epics/epic_02_skills_and_progression.md` | Slice 4 pointer consistency (WIP edits). |
|
||||
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | E2.M3 cross-link (WIP edits). |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | E2.M3 note (WIP edits). |
|
||||
| `docs/plans/E2M3-pre-production-backlog.md` | Pre-production backlog (untracked → tracked). |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| **CI / PR gate** | `.github/workflows/pr-gate.yml` already runs `python scripts/validate_content.py` — extend script; no workflow change required unless gate message needs updating. |
|
||||
| **Manual negative cases** | Temporarily break catalog (unknown `skillId`, duplicate `perkId`, orphan `perkIds` reference, extra `refine` track) and confirm non-zero exit + actionable stderr. |
|
||||
| **Manual happy path** | `pip install -r scripts/requirements-content.txt && python scripts/validate_content.py` reports mastery + existing catalogs OK. |
|
||||
| **Dedicated pytest** | **Not required** for NEO-45 (same as NEO-33 / NEO-39 content-only pattern); optional follow-up if we want fixture-based script tests later. |
|
||||
| **Server tests** | **None** — server load is NEO-46. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Tier index convention:** Use explicit `tierIndex` (1-based) in JSON for stable server persistence keys in NEO-47; document in schema description.
|
||||
- **Catalog growth:** Slice 4 gate mirrors NEO-33 — relaxing “salvage only” requires a new Linear issue to change `validate_content.py` constants.
|
||||
- **Drift vs NEO-46:** Server loader must mirror Python rules; NEO-46 plan should reference this plan’s gate constants and validate **`tierIndex`** uniqueness / sequential 1..N per track (schema allows gaps today).
|
||||
- **None** blocking at kickoff after clarifications above.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Top-level `perks` map | User kickoff choice; avoids duplicating perk metadata on every branch reference. |
|
||||
| Empty tier-1 `perkIds` | User kickoff choice; matches Linear AC (“tier 2 … one perk per branch path”). |
|
||||
| Frozen tier-2 perk ids `salvage_scrap_efficiency_1`, `salvage_bulk_haul_1` | Prefix with skill + branch semantics; unique globally for future multi-track catalogs. |
|
||||
| Include WIP E2.M3 docs on branch | User kickoff choice; keeps decomposition aligned with Slice 4 backlog before implementation PR. |
|
||||
|
||||
## Verification (implementation)
|
||||
|
||||
- `python3 scripts/validate_content.py` — exit 0; reports 1 mastery catalog + 1 salvage track.
|
||||
- `python3 scripts/check_decomposition_language.py` — exit 0.
|
||||
- Negative cases (restored catalog after each): unknown `skillId`, orphan `perkId`, duplicate `perkId` reference, Slice 4 multi-track gate — all non-zero exit with actionable stderr.
|
||||
|
|
@ -0,0 +1,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-47–49).
|
||||
- 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 *N−1*
|
||||
- **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. |
|
||||
|
|
@ -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 2’s `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 1’s 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 pick’s `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 branch’s 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. |
|
||||
|
|
@ -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. |
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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. |
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Code review — NEO-45 (salvage mastery catalog + schemas + CI)
|
||||
|
||||
**Date:** 2026-05-16
|
||||
|
||||
**Scope:** Branch `NEO-45-salvage-mastery-catalog-schemas-ci`, commit `f5f04d8` (`NEO-45: lock prototype salvage mastery catalog, schemas, and CI.`). Issue **NEO-45**.
|
||||
|
||||
**Base:** `main` (parent of `f5f04d8`)
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
The change delivers NEO-45 as a **content-only** slice: `mastery-catalog.schema.json`, prototype `prototype_salvage_mastery.json` (one **salvage** track, tier 1 branch pick @ level 2 with empty `perkIds`, tier 2 perks @ level 4), extended `validate_content.py` with schema validation, `skillId` cross-check, perk map key/`id` alignment, duplicate `perkId` / reference guards, branch-set continuity across tiers, strictly increasing `requiredLevel`, and a Slice 4 **salvage-only / exactly-one-track** gate. Documentation (E2.M3 module, CT.M1, epic Slice 4, alignment table, implementation plan, backlog) is thorough and matches kickoff decisions. Risk is low: no server/C#/client surface; PR gate already runs the script. Main gap is **status field drift** between the alignment row (**In Progress**) and the register / E2.M3 Summary (**Planned**).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Assessment |
|
||||
|----------|------------|
|
||||
| `docs/plans/NEO-45-implementation-plan.md` | **Matches** — schema shape, catalog layout, validator gates, frozen ids, out-of-scope (NEO-46+), verification steps align with the implementation. |
|
||||
| `docs/plans/E2M3-pre-production-backlog.md` | **Matches** — E2M3-01 acceptance criteria marked done (NEO-45). |
|
||||
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | **Matches** — data contract (`perks` map + `tracks`), designer note, freeze table, **In Progress** status. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — CI paragraph documents mastery validation alongside skills. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E2.M3 row aligned with register and module **In Progress**. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E2.M3 **In Progress**, Depends On **E2.M1, E2.M2**, NEO-45 landed in note. |
|
||||
| `docs/decomposition/epics/epic_02_skills_and_progression.md` | **Matches** — Slice 4 scope, NEO-45→49 chain, E2.M4 separation. |
|
||||
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | **Matches** — NEO-43 landed line + E2.M3 level-up hook cross-link. |
|
||||
| `content/README.md` | **Matches** — Slice 4 freeze reminder and links to E2.M3. |
|
||||
|
||||
**Register / tracking:** ~~Update **Planned → In Progress** for E2.M3~~ **Done** (register, module Summary, backlog checkboxes).
|
||||
|
||||
## Blocking issues
|
||||
|
||||
_None._
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Align E2.M3 status across docs** — `documentation_and_implementation_alignment.md` marks E2.M3 **In Progress** with NEO-45 landed, but `module_dependency_register.md` and `E2_M3_MasteryAndPerkUnlocks.md` Summary still say **Planned**. Pick one convention (recommended: **In Progress** on register + module Summary when catalog merges, matching E2.M1 after NEO-33).~~ **Done.** Register + module Summary → **In Progress**; Depends On → **E2.M1, E2.M2**; E2.M3 note cites NEO-45 landed.
|
||||
|
||||
2. ~~**Backlog checkboxes** — In `docs/plans/E2M3-pre-production-backlog.md`, flip E2M3-01 acceptance criteria to `[x]` (or add a “Done in NEO-45” note) so the backlog does not contradict `NEO-45-implementation-plan.md`.~~ **Done.**
|
||||
|
||||
3. ~~**Optional CI gate: unreferenced perks** — `perks` map entries with no `perkIds` reference currently pass validation (orphan perk → exit 0). Consider rejecting dead `PerkDef` rows in a follow-up if designers should not ship unused defs; not required by NEO-45 AC.~~ **Done.** `validate_content.py` rejects unreferenced `perks` map entries per file.
|
||||
|
||||
4. ~~**NEO-46 parity** — Plan already flags mirroring Python gate constants in C# loader; when implementing NEO-46, also consider validating **`tierIndex`** uniqueness / sequential 1..N per track (schema allows gaps today; catalog is correct).~~ **Done.** Noted in `NEO-45-implementation-plan.md` open questions for NEO-46.
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~**Nit:** Tier 2 branches in `prototype_salvage_mastery.json` omit `displayName` while tier 1 includes it; valid per schema but slightly uneven for authoring.~~ **Done.** Tier 2 branches include `displayName`.
|
||||
- ~~**Nit:** `global_referenced_perk_ids` treats a perk as globally single-use across branches/files — correct for mutually exclusive v1 trees, but document if a future catalog ever needs the same `perkId` on two branches (unlikely).~~ **Done.** Comment in `validate_content.py`.
|
||||
- **Nit:** Adding a second **salvage** track with duplicated perk references fails on duplicate reference errors before the Slice 4 “exactly one track” message; harmless, but authors adding a second track with no perk refs get the clearer Slice 4 error.
|
||||
|
||||
## Verification
|
||||
|
||||
Run before merge:
|
||||
|
||||
```bash
|
||||
pip install -r scripts/requirements-content.txt # if needed
|
||||
python3 scripts/validate_content.py
|
||||
python3 scripts/check_decomposition_language.py
|
||||
```
|
||||
|
||||
Manual negative cases (restore catalog after each):
|
||||
|
||||
- Unknown `skillId` → non-zero, actionable stderr.
|
||||
- Unknown `perkIds` reference → non-zero.
|
||||
- Duplicate perk reference across branches → non-zero.
|
||||
- Second `salvage` track (no perk refs) → Slice 4 gate non-zero.
|
||||
|
||||
No `dotnet test` or Godot run required for this story (content-only per plan).
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Documentation review — NEO-45 (E2.M3 salvage mastery catalog + schemas + CI)
|
||||
|
||||
**Date:** 2026-05-16
|
||||
|
||||
**Scope:** E2.M3 Slice 4 documentation and content contract for [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45): `docs/plans/NEO-45-implementation-plan.md`, `docs/plans/E2M3-pre-production-backlog.md`, `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`, related Epic 2 / CT.M1 / alignment / register / `content/README.md`, and hybrid-progression ground truth (`docs/game-design/skills.md`, `progression.md`, `gigs.md`).
|
||||
|
||||
**Base:** Workspace as of 2026-05-16; `python3 scripts/validate_content.py` and `python3 scripts/check_decomposition_language.py` both exit 0.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-45 documentation is coherent with shipped content: implementation plan, E2.M3 module (freeze table, **`perks` map** data contract, **In Progress** status, NEO-46 handoff), CT.M1, `content/README.md`, alignment/register rows, and E2M3-01 backlog checkboxes align with the **salvage-only** prototype track and CI gates. Hybrid vocabulary is preserved (skill mastery vs gig progression; `gigs.md` clarifies gig mastery is separate from E2.M3).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEO-45-implementation-plan.md` | **Matches** — kickoff decisions, frozen ids, technical approach, acceptance checklist, and verification steps align with `content/mastery/prototype_salvage_mastery.json`, `content/schemas/mastery-catalog.schema.json`, and `scripts/validate_content.py` behavior. |
|
||||
| `docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md` | **Matches** — freeze table, designer note, **`perks` map** / `tiers[]` shape, **In Progress** status, NEO-46 handoff, NEO-45 acceptance footnote. |
|
||||
| `docs/plans/E2M3-pre-production-backlog.md` | **Matches** — E2M3-01 acceptance criteria marked done (NEO-45); E2M3-02 points at NEO-45 gate constants. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — mastery paragraph matches script gates (schema, `skillId`, duplicate `perkId`, branch integrity, salvage-only Slice 4). |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E2.M3 row correctly states NEO-45 landed and follow-on NEO-46–49 planned. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E2.M3 **In Progress**, Depends On **E2.M1, E2.M2**, NEO-45 landed in note. |
|
||||
| `docs/decomposition/epics/epic_02_skills_and_progression.md` | **Matches** — E2.M3 / Slice 4 pointers, skill-vs-gig XP split, and salvage flagship scope. |
|
||||
| `docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md` | **Matches** — E2.M3 cross-link for level-up re-evaluation; no mastery XP bar implied. |
|
||||
| `content/README.md` | **Matches** — `mastery/` row, Slice 4 freeze, validate command; anchors target E2.M3 designer + freeze headings. |
|
||||
| `content/schemas/mastery-catalog.schema.json` | **Matches** — documents E2.M3 module; `schemaVersion`, `perks`, `tracks`, `tierIndex`, stable id patterns. |
|
||||
| `content/mastery/prototype_salvage_mastery.json` | **Matches** — one `salvage` track; tier 1 empty `perkIds`; tier 2 perks + branch `displayName`; frozen ids per module freeze table. |
|
||||
| `docs/game-design/skills.md` | **Matches** — Mastery and perks section points to E2.M3; respec open; gig tree separate. |
|
||||
| `docs/game-design/progression.md` | **Matches** — skill vs gig vocabulary; no mastery XP conflation. |
|
||||
| `docs/game-design/gigs.md` | **Matches** — gig mastery **open**; line clarifies analogy to skill branch/perk depth, not E2.M3 module. |
|
||||
| `docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md` | **N/A** — no mastery-specific change required; `salvage` id exists for cross-check. |
|
||||
| `scripts/validate_content.py` | **Matches** — docs describe gates implemented in `_validate_mastery_catalogs` / `_prototype_slice4_gate`. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**E2.M3 module + register status** — Set **Status** to **In Progress** in `E2_M3_MasteryAndPerkUnlocks.md` (summary table) and `module_dependency_register.md` (E2.M3 row), mirroring E2.M1 after NEO-33 and the alignment doc **In Progress** row. Reserve **Done** for when NEO-49 closes the Slice 4 engine/HTTP/telemetry slice.~~ **Done.**
|
||||
|
||||
2. ~~**E2M3-pre-production-backlog E2M3-01 checkboxes** — Mark the three E2M3-01 acceptance criteria `[x]` when NEO-45 is merged/Done in Linear, or add a one-line note under E2M3-01: “Catalog + CI: **NEO-45** (see implementation plan).”~~ **Done.**
|
||||
|
||||
3. ~~**E2.M3 Data contract — catalog shape** — Add a short subsection (or extend **Data contract**) documenting the shipped JSON layout: top-level **`perks`** map (keys = `perkId`), **`tracks`** array, **`MasteryTrack.tiers[]`** with `tierIndex` / `requiredLevel` / `branches[]` / `perkIds[]`. Link `content/schemas/mastery-catalog.schema.json` and note map-key === `PerkDef.id` (enforced in CI).~~ **Done.**
|
||||
|
||||
4. ~~**NEO-46 plan handoff** — When kicking off NEO-46, reference NEO-45 plan **gate constants** (salvage-only track count, branch-set equality tier-to-tier) so server loader parity stays explicit (already listed as a risk in the NEO-45 plan).~~ **Done.** E2.M3 **NEO-46 handoff** + E2M3-02 backlog pointer; NEO-45 plan open questions.
|
||||
|
||||
5. ~~**`gigs.md` gig-mastery line** — Clarify “E2.M3-style expression” means **analogous branch/perk depth for gigs**, not the E2.M3 **skill** module; optional cross-link to a future gig-mastery epic/module when named.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- ~~Tier 2 branches in `prototype_salvage_mastery.json` omit `displayName` while tier 1 includes it — valid per schema; optional designer note that `displayName` is optional per branch row.~~ **Done.** Catalog + designer note.
|
||||
- ~~Module **Acceptance criteria (E2.M3 / Slice 4)** bullets describe **runtime** unlock behavior (NEO-47+); consider a footnote that NEO-45 satisfies **data + CI** only.~~ **Done.**
|
||||
|
||||
## Verification
|
||||
|
||||
- `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` — expect `content OK` with 1 mastery catalog and 1 mastery track.
|
||||
- `python3 scripts/check_decomposition_language.py` — exit 0.
|
||||
- After doc edits: grep `E2.M3` status in `module_dependency_register.md` vs `documentation_and_implementation_alignment.md` for consistency.
|
||||
- Open `content/README.md` links to E2.M3 `#designer-note-tiers-branches-and-level-gates` and `#prototype-slice-4-freeze--salvage-flagship-neo-45` in the GitHub UI (anchor fragility check).
|
||||
- Manual negative cases from NEO-45 plan (unknown `skillId`, duplicate `perkId`, extra track) — confirm stderr messages remain actionable.
|
||||
|
|
@ -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 2–3 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 1–3 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 1–3 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 Bruno’s 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`.
|
||||
|
|
@ -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 1–3 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).
|
||||
|
|
@ -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 branch’s 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.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 1–3 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.M1’s 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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!);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue