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
parent
898f935b4f
commit
174c390aa4
|
|
@ -0,0 +1,25 @@
|
|||
meta {
|
||||
name: GET health (mastery catalog boot NEO-46)
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
NEO-46 loads content/mastery/*_mastery.json at startup (fail-fast). No mastery HTTP API in this story — use this request to confirm the host started after catalog validation.
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
meta {
|
||||
name: mastery-catalog
|
||||
}
|
||||
|
|
@ -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-37–NEO-41, NEO-42–NEO-44 |
|
||||
| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
|
||||
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@
|
|||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [ ] Fail-fast boot load for mastery catalog(s) under agreed content path.
|
||||
- [ ] `IMasteryCatalogRegistry` resolves tracks by `skillId` and enumerates perk definitions.
|
||||
- [ ] Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`.
|
||||
- [ ] Unit tests for loader happy path + duplicate id / unknown skill deny.
|
||||
- [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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to validated mastery tracks and perks loaded at startup (<see cref="MasteryCatalog"/>).
|
||||
/// </summary>
|
||||
public interface IMasteryCatalogRegistry
|
||||
{
|
||||
/// <summary>Attempts to resolve a track by <c>skillId</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||
bool TryGetTrack(string? skillId, [NotNullWhen(true)] out MasteryTrackRow? track);
|
||||
|
||||
/// <summary>Attempts to resolve a perk by stable <c>id</c>. Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
|
||||
bool TryGetPerk(string? perkId, [NotNullWhen(true)] out PerkDefRow? perk);
|
||||
|
||||
/// <summary>Every loaded track, ordered by <see cref="MasteryTrackRow.SkillId"/> (ordinal).</summary>
|
||||
IReadOnlyList<MasteryTrackRow> GetTracksInSkillIdOrder();
|
||||
|
||||
/// <summary>Every loaded perk, ordered by <see cref="PerkDefRow.Id"/> (ordinal).</summary>
|
||||
IReadOnlyList<PerkDefRow> GetPerksInIdOrder();
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>One branch row within a mastery tier (NEO-46).</summary>
|
||||
public sealed record MasteryBranchRow(
|
||||
string BranchId,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<string> PerkIds);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>In-memory mastery catalog loaded at startup (NEO-46). Prefer <see cref="IMasteryCatalogRegistry"/> for lookups.</summary>
|
||||
public sealed class MasteryCatalog
|
||||
{
|
||||
public MasteryCatalog(
|
||||
string masteryDirectory,
|
||||
IReadOnlyDictionary<string, MasteryTrackRow> tracksBySkillId,
|
||||
IReadOnlyDictionary<string, PerkDefRow> perksById,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
MasteryDirectory = masteryDirectory;
|
||||
TracksBySkillId = new ReadOnlyDictionary<string, MasteryTrackRow>(
|
||||
new Dictionary<string, MasteryTrackRow>(tracksBySkillId, StringComparer.Ordinal));
|
||||
PerksById = new ReadOnlyDictionary<string, PerkDefRow>(
|
||||
new Dictionary<string, PerkDefRow>(perksById, StringComparer.Ordinal));
|
||||
CatalogJsonFileCount = catalogJsonFileCount;
|
||||
}
|
||||
|
||||
/// <summary>Absolute path to the directory enumerated for <c>*_mastery.json</c> catalogs.</summary>
|
||||
public string MasteryDirectory { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, MasteryTrackRow> TracksBySkillId { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, PerkDefRow> PerksById { get; }
|
||||
|
||||
public int TrackCount => TracksBySkillId.Count;
|
||||
|
||||
public int PerkCount => PerksById.Count;
|
||||
|
||||
/// <summary>Number of <c>*_mastery.json</c> files under <see cref="MasteryDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; }
|
||||
}
|
||||
|
|
@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>Resolves mastery catalog paths for local dev, tests, and container layouts (NEO-46).</summary>
|
||||
public static class MasteryCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/mastery</c> directory.</summary>
|
||||
public static string? TryDiscoverMasteryDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "mastery");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the mastery catalog directory.
|
||||
/// Empty <paramref name="configuredMasteryDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveMasteryDirectory(string? configuredMasteryDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredMasteryDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverMasteryDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:MasteryDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/mastery'). " +
|
||||
"Set Content:MasteryDirectory in configuration or environment (e.g. Content__MasteryDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredMasteryDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a mastery catalog document (Draft 2020-12).</summary>
|
||||
public static string ResolveMasteryCatalogSchemaPath(
|
||||
string masteryDirectory,
|
||||
string? configuredSchemaPath,
|
||||
string contentRootPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredSchemaPath))
|
||||
{
|
||||
var trimmed = configuredSchemaPath.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(masteryDirectory, "..", "schemas", "mastery-catalog.schema.json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>Adapter over <see cref="MasteryCatalog"/> (NEO-46).</summary>
|
||||
public sealed class MasteryCatalogRegistry(MasteryCatalog catalog) : IMasteryCatalogRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool TryGetTrack(string? skillId, [NotNullWhen(true)] out MasteryTrackRow? track)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skillId))
|
||||
{
|
||||
track = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return catalog.TracksBySkillId.TryGetValue(skillId, out track);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetPerk(string? perkId, [NotNullWhen(true)] out PerkDefRow? perk)
|
||||
{
|
||||
if (string.IsNullOrEmpty(perkId))
|
||||
{
|
||||
perk = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return catalog.PerksById.TryGetValue(perkId, out perk);
|
||||
}
|
||||
|
||||
public IReadOnlyList<MasteryTrackRow> GetTracksInSkillIdOrder() =>
|
||||
catalog.TracksBySkillId.Values.OrderBy(t => t.SkillId, StringComparer.Ordinal).ToList();
|
||||
|
||||
public IReadOnlyList<PerkDefRow> GetPerksInIdOrder() =>
|
||||
catalog.PerksById.Values.OrderBy(p => p.Id, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>DI registration for the fail-fast mastery catalog (NEO-46).</summary>
|
||||
public static class MasteryCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="MasteryCatalog"/> and <see cref="IMasteryCatalogRegistry"/> as singletons.</summary>
|
||||
public static IServiceCollection AddMasteryCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<MasteryCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var skillCatalog = sp.GetRequiredService<SkillDefinitionCatalog>();
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Mastery.MasteryCatalog");
|
||||
|
||||
var masteryDir = MasteryCatalogPathResolution.ResolveMasteryDirectory(
|
||||
opts.MasteryDirectory,
|
||||
hostEnv.ContentRootPath);
|
||||
var schemaPath = MasteryCatalogPathResolution.ResolveMasteryCatalogSchemaPath(
|
||||
masteryDir,
|
||||
opts.MasteryCatalogSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
var knownSkillIds = skillCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal);
|
||||
return MasteryCatalogLoader.Load(masteryDir, schemaPath, knownSkillIds, logger);
|
||||
});
|
||||
|
||||
services.AddSingleton<IMasteryCatalogRegistry>(sp =>
|
||||
new MasteryCatalogRegistry(sp.GetRequiredService<MasteryCatalog>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>One tier row within a <see cref="MasteryTrackRow"/> (NEO-46).</summary>
|
||||
public sealed record MasteryTierRow(
|
||||
int TierIndex,
|
||||
int RequiredLevel,
|
||||
IReadOnlyList<MasteryBranchRow> Branches);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>One validated <c>MasteryTrack</c> from <c>content/mastery/*.json</c> (NEO-46).</summary>
|
||||
public sealed record MasteryTrackRow(string SkillId, IReadOnlyList<MasteryTierRow> Tiers);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
namespace NeonSprawl.Server.Game.Mastery;
|
||||
|
||||
/// <summary>One validated <c>PerkDef</c> from <c>content/mastery/*.json</c> (NEO-46).</summary>
|
||||
public sealed record PerkDefRow(string Id, string DisplayName, string? EffectKind);
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue