NEO-46: fail-fast mastery catalog load at server startup.

Mirror validate_content.py gates in MasteryCatalogLoader, expose IMasteryCatalogRegistry, cross-check skillIds against the skill catalog, and enforce tierIndex 1..N per track.
pull/80/head
VinPropane 2026-05-17 18:06:50 -04:00
parent 898f935b4f
commit 174c390aa4
23 changed files with 1035 additions and 5 deletions

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

@ -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.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) | | 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.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 | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37NEO-41, NEO-42NEO-44 |
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` | | E3.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

@ -40,10 +40,10 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] Fail-fast boot load for mastery catalog(s) under agreed content path. - [x] Fail-fast boot load for mastery catalog(s) under agreed content path.
- [ ] `IMasteryCatalogRegistry` resolves tracks by `skillId` and enumerates perk definitions. - [x] `IMasteryCatalogRegistry` resolves tracks by `skillId` and enumerates perk definitions.
- [ ] Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`. - [x] Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`.
- [ ] Unit tests for loader happy path + duplicate id / unknown skill deny. - [x] Unit tests for loader happy path + duplicate id / unknown skill deny.
## Technical approach ## Technical approach

View File

@ -0,0 +1,252 @@
using System.Collections.Frozen;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Tests;
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 static (string Root, string MasteryDir, string SchemaPath) CreateTempContentLayout()
{
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 (root.FullName, masteryDir, schemaPath);
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var catalog = MasteryCatalogLoader.Load(masteryDir, 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_WhenTracksIsNotArray()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(masteryDir, "bad_mastery.json"), """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, 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_WhenUnknownSkillId()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, 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
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(masteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
File.WriteAllText(Path.Combine(masteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, 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_WhenUnreferencedPerkInMap()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
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(masteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, 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_WhenSlice4TrackCountWrong()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
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(masteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, 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_WhenTierIndexHasGap()
{
// Arrange
var (_, masteryDir, schemaPath) = CreateTempContentLayout();
var gapTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 3", StringComparison.Ordinal);
File.WriteAllText(Path.Combine(masteryDir, "gap_tier_mastery.json"), gapTier, Encoding.UTF8);
// Act
var ex = Record.Exception(() =>
MasteryCatalogLoader.Load(masteryDir, 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 async Task Host_ShouldResolveMasteryCatalogFromDi_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<MasteryCatalog>();
Assert.Equal(1, catalog.TrackCount);
Assert.True(catalog.TracksBySkillId.ContainsKey("salvage"));
}
[Fact]
public void Host_ShouldFailStartup_WhenMasteryDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-mastery-" + Guid.NewGuid().ToString("n"));
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);
}
}

View File

@ -0,0 +1,89 @@
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 async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IMasteryCatalogRegistry>();
var foundTrack = registry.TryGetTrack("salvage", out var track);
var foundPerk = registry.TryGetPerk("salvage_scrap_efficiency_1", out var perk);
// Assert
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.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Tests.Game.PositionState; namespace NeonSprawl.Server.Tests.Game.PositionState;
@ -23,7 +24,11 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "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:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureAppConfiguration((_, config) => builder.ConfigureAppConfiguration((_, config) =>
{ {

View File

@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing; using Microsoft.Extensions.Time.Testing;
using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using Npgsql; using Npgsql;
@ -26,7 +27,11 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory) var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); "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:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.ConfigureTestServices(services => 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). /// When unset, resolved as <c>{parent of skills directory}/schemas/skill-def.schema.json</c> (same layout as the repo).
/// </summary> /// </summary>
public string? SkillDefSchemaPath { get; set; } 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.AbilityInput;
using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills; using NeonSprawl.Server.Game.Skills;
using NeonSprawl.Server.Game.Targeting; using NeonSprawl.Server.Game.Targeting;
@ -11,9 +12,11 @@ builder.Services.AddSkillProgressionStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore(); builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>(); builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build(); var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>(); _ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>(); _ = app.Services.GetRequiredService<ISkillLevelCurve>();
app.MapGet("/", () => Results.Text( 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. 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) ## 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/`. **`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/`.