Merge pull request #80 from ViPro-Technologies/NEO-46-mastery-catalog-startup-load

NEO-46: Server loads mastery catalog at startup (fail-fast)
pull/84/head
VinPropane 2026-05-17 18:28:19 -04:00 committed by GitHub
commit 619146da85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1574 additions and 4 deletions

11
.cursor/hooks.json 100644
View File

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

View File

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

View File

@ -5,6 +5,8 @@ alwaysApply: true
# Commits and review (Neon Sprawl)
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).
## 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.”

View File

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

View File

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

View File

@ -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.

View File

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

View File

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

View File

@ -8,7 +8,7 @@
| **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** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed) |
| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed; **NEO-46** server fail-fast load landed) |
## Linear backlog (Epic 2 Slice 4 — E2.M3)
@ -75,7 +75,7 @@ Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId`
## NEO-46 handoff (server catalog load)
When [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) kicks off, mirror the Python CI gates documented in [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries). Consider **`tierIndex`** uniqueness and sequential 1..N per track at server boot (schema allows gaps today).
**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+.
## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45))

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ Validates:
- skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json
- mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json
(post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot NEO-46)
"""
from __future__ import annotations
@ -51,6 +52,26 @@ 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],
@ -130,11 +151,16 @@ def _validate_mastery_catalogs(
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(
@ -203,6 +229,11 @@ def _validate_mastery_catalogs(
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(

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
using Npgsql;
@ -26,7 +27,11 @@ 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.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services =>
{

View File

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

View File

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

View File

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

View File

@ -0,0 +1,358 @@
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).</summary>
internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList<int> tierIndexValues)
{
if (tierIndexValues.Count == 0)
return null;
var seen = new HashSet<int>();
foreach (var value in tierIndexValues)
{
if (!seen.Add(value))
{
return $"error: {filePath}: tracks[{trackIndex}] duplicate tierIndex {value}";
}
}
var expected = Enumerable.Range(1, tierIndexValues.Count).ToHashSet();
if (!seen.SetEquals(expected))
{
var sorted = string.Join(", ", seen.Order().Select(v => v.ToString()));
return
$"error: {filePath}: tracks[{trackIndex}] tierIndex values must be unique and sequential 1..{tierIndexValues.Count}, got [{sorted}]";
}
return null;
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, sink);
}
if (!r.HasErrors)
return;
foreach (var kv in r.Errors!)
{
var loc = r.InstanceLocation?.ToString();
if (string.IsNullOrEmpty(loc) || loc == "#")
loc = "(root)";
sink.Add($"error: {filePath} {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Mastery catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,31 @@
namespace NeonSprawl.Server.Game.Mastery;
/// <summary>
/// Prototype Slice 4 mastery gate (NEO-45), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_SLICE4_SALVAGE_SKILL_ID</c> / <c>_prototype_slice4_gate</c>.
/// </summary>
public static class PrototypeSlice4MasteryCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE4_SALVAGE_SKILL_ID</c>.</summary>
public const string SalvageSkillId = "salvage";
/// <summary>Returns a human-readable error if the Slice 4 contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetSlice4GateError(IReadOnlyList<string> trackSkillIds)
{
if (trackSkillIds.Count != 1)
{
return
"error: prototype Slice 4 expects exactly one MasteryTrack across all mastery files, " +
$"got {trackSkillIds.Count} track(s) with skillIds [{string.Join(", ", trackSkillIds.Select(s => "'" + s + "'"))}]";
}
if (trackSkillIds[0] != SalvageSkillId)
{
return
"error: prototype Slice 4 expects the sole track skillId " +
$"'{SalvageSkillId}', got '{trackSkillIds[0]}'";
}
return null;
}
}

View File

@ -16,4 +16,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of skills directory}/schemas/skill-def.schema.json</c> (same layout as the repo).
/// </summary>
public string? SkillDefSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/mastery</c> directory.
/// </summary>
public string? MasteryDirectory { get; set; }
/// <summary>
/// Optional override for <c>mastery-catalog.schema.json</c>.
/// When unset, resolved as <c>{parent of mastery directory}/schemas/mastery-catalog.schema.json</c>.
/// </summary>
public string? MasteryCatalogSchemaPath { get; set; }
}

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting;
@ -11,9 +12,11 @@ builder.Services.AddSkillProgressionStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
app.MapGet("/", () => Results.Text(

View File

@ -37,6 +37,19 @@ On startup the host loads every **`*_skills.json`** under the skills directory,
On success, **Information** logs include the resolved skills directory path and distinct skill count.
## Mastery catalog (`content/mastery`, NEO-46)
After the skill catalog loads, the host loads every **`*_mastery.json`** under the mastery directory, validates each file against **`content/schemas/mastery-catalog.schema.json`**, cross-checks track **`skillId`** values against **`ISkillDefinitionRegistry`**, and enforces the same post-schema gates as **`scripts/validate_content.py`** (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype **Slice 4** single **`salvage`** track). The server also requires **`tierIndex`** values per track to be **unique and sequential 1..N** (stricter than CI today). Invalid data **exits during startup**—no silent fallback.
| Config | Meaning |
|--------|---------|
| **`Content:MasteryDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** for **`content/mastery`**. |
| **`Content:MasteryCatalogSchemaPath`** | Optional override for **`mastery-catalog.schema.json`**. When unset, **`{parent of mastery directory}/schemas/mastery-catalog.schema.json`**. |
**Docker / CI:** include **`content/mastery`** and **`content/schemas/mastery-catalog.schema.json`** in the mounted **`content/`** tree; set **`Content__MasteryDirectory`** when layout differs.
On success, **Information** logs include the resolved mastery directory, track count, and perk count. Game code should use **`IMasteryCatalogRegistry`** for lookups (NEO-47+).
## Skill definitions (NEO-36)
**`GET /game/world/skill-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`skills`**) backed by **`ISkillDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-36 implementation plan](../../docs/plans/NEO-36-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-36.md`](../../docs/manual-qa/NEO-36.md); Bruno: `bruno/neon-sprawl-server/skill-definitions/`.