diff --git a/bruno/neon-sprawl-server/mastery-catalog/Health after mastery catalog load.bru b/bruno/neon-sprawl-server/mastery-catalog/Health after mastery catalog load.bru new file mode 100644 index 0000000..423142a --- /dev/null +++ b/bruno/neon-sprawl-server/mastery-catalog/Health after mastery catalog load.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/mastery-catalog/folder.bru b/bruno/neon-sprawl-server/mastery-catalog/folder.bru new file mode 100644 index 0000000..db9d82d --- /dev/null +++ b/bruno/neon-sprawl-server/mastery-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: mastery-catalog +} diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index f1bcce7..c6314bb 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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/` | diff --git a/docs/plans/NEO-46-implementation-plan.md b/docs/plans/NEO-46-implementation-plan.md index 2607430..987209d 100644 --- a/docs/plans/NEO-46-implementation-plan.md +++ b/docs/plans/NEO-46-implementation-plan.md @@ -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 diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs new file mode 100644 index 0000000..6b74c1a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs @@ -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 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(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(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(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(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(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(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(); + 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().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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs new file mode 100644 index 0000000..adde960 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs @@ -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(StringComparer.Ordinal) { ["salvage"] = track }, + new Dictionary(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(StringComparer.Ordinal), + new Dictionary(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(StringComparer.Ordinal), + new Dictionary(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(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs new file mode 100644 index 0000000..0d323e9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs @@ -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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 9993dab..818f6d8 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -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 { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index ca90dc3..4882a9d 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -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 { diff --git a/server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs b/server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs new file mode 100644 index 0000000..b628bfa --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Mastery; + +/// +/// Read-only access to validated mastery tracks and perks loaded at startup (). +/// +public interface IMasteryCatalogRegistry +{ + /// Attempts to resolve a track by skillId. Unknown ids and null return false without throwing. + bool TryGetTrack(string? skillId, [NotNullWhen(true)] out MasteryTrackRow? track); + + /// Attempts to resolve a perk by stable id. Unknown ids and null return false without throwing. + bool TryGetPerk(string? perkId, [NotNullWhen(true)] out PerkDefRow? perk); + + /// Every loaded track, ordered by (ordinal). + IReadOnlyList GetTracksInSkillIdOrder(); + + /// Every loaded perk, ordered by (ordinal). + IReadOnlyList GetPerksInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs new file mode 100644 index 0000000..75cbd92 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// One branch row within a mastery tier (NEO-46). +public sealed record MasteryBranchRow( + string BranchId, + string? DisplayName, + IReadOnlyList PerkIds); diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs new file mode 100644 index 0000000..8eb0e88 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs @@ -0,0 +1,35 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Mastery; + +/// In-memory mastery catalog loaded at startup (NEO-46). Prefer for lookups. +public sealed class MasteryCatalog +{ + public MasteryCatalog( + string masteryDirectory, + IReadOnlyDictionary tracksBySkillId, + IReadOnlyDictionary perksById, + int catalogJsonFileCount) + { + MasteryDirectory = masteryDirectory; + TracksBySkillId = new ReadOnlyDictionary( + new Dictionary(tracksBySkillId, StringComparer.Ordinal)); + PerksById = new ReadOnlyDictionary( + new Dictionary(perksById, StringComparer.Ordinal)); + CatalogJsonFileCount = catalogJsonFileCount; + } + + /// Absolute path to the directory enumerated for *_mastery.json catalogs. + public string MasteryDirectory { get; } + + public IReadOnlyDictionary TracksBySkillId { get; } + + public IReadOnlyDictionary PerksById { get; } + + public int TrackCount => TracksBySkillId.Count; + + public int PerkCount => PerksById.Count; + + /// Number of *_mastery.json files under . + public int CatalogJsonFileCount { get; } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs new file mode 100644 index 0000000..4a1edd9 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs @@ -0,0 +1,358 @@ +using System.Text; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Mastery; + +/// Loads and validates content/mastery/*_mastery.json using the same rules as scripts/validate_content.py (NEO-46). +public static class MasteryCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static MasteryCatalog Load( + string masteryDirectory, + string schemaPath, + IReadOnlySet knownSkillIds, + ILogger logger) + { + masteryDirectory = Path.GetFullPath(masteryDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + 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(StringComparer.Ordinal); + var globalReferencedPerkIds = new Dictionary(StringComparer.Ordinal); + var allTrackSkillIds = new List(); + var perksById = new Dictionary(StringComparer.Ordinal); + var tracksBySkillId = new Dictionary(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(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(); + 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.Empty; + string? effectKind = null; + if (perkObj["effectKind"] is JsonValue ek) + effectKind = ek.GetValue(); + 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(); + 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? prevTierBranchIds = null; + var tierIndexValues = new List(); + + var parsedTiers = new List(); + + for (var tierI = 0; tierI < tiersArray.Count; tierI++) + { + if (tiersArray[tierI] is not JsonObject tierObj) + continue; + + var tierIndex = (tierObj["tierIndex"] as JsonValue)?.GetValue(); + if (tierIndex is int idx) + tierIndexValues.Add(idx); + + var requiredLevel = (tierObj["requiredLevel"] as JsonValue)?.GetValue(); + 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(); + var parsedBranches = new List(); + + for (var bi = 0; bi < branchesArray.Count; bi++) + { + if (branchesArray[bi] is not JsonObject branchObj) + continue; + + var branchId = (branchObj["branchId"] as JsonValue)?.GetValue(); + 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(); + + var perkIdsList = new List(); + if (branchObj["perkIds"] is JsonArray perkIdsArray) + { + foreach (var perkIdNode in perkIdsArray) + { + if (perkIdNode is not JsonValue pv) + continue; + + var perkId = pv.GetValue(); + 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); + } + + /// Server-only gate: unique tierIndex values must be exactly 1..N (NEO-46 kickoff). + internal static string? TryGetTierIndexGateError(string filePath, int trackIndex, IReadOnlyList tierIndexValues) + { + if (tierIndexValues.Count == 0) + return null; + + var seen = new HashSet(); + 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 CollectSchemaMessages(EvaluationResults eval, string filePath) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, List 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 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()); + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs new file mode 100644 index 0000000..03b503d --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// Resolves mastery catalog paths for local dev, tests, and container layouts (NEO-46). +public static class MasteryCatalogPathResolution +{ + /// Walks and parents for an existing content/mastery directory. + 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; + } + + /// + /// Resolves the mastery catalog directory. + /// Empty triggers discovery from . + /// + 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)); + } + + /// Resolves JSON Schema path for a mastery catalog document (Draft 2020-12). + 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")); + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs new file mode 100644 index 0000000..38cf143 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Mastery; + +/// Adapter over (NEO-46). +public sealed class MasteryCatalogRegistry(MasteryCatalog catalog) : IMasteryCatalogRegistry +{ + /// + 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); + } + + /// + 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 GetTracksInSkillIdOrder() => + catalog.TracksBySkillId.Values.OrderBy(t => t.SkillId, StringComparer.Ordinal).ToList(); + + public IReadOnlyList GetPerksInIdOrder() => + catalog.PerksById.Values.OrderBy(p => p.Id, StringComparer.Ordinal).ToList(); +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..f3d95b8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs @@ -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; + +/// DI registration for the fail-fast mastery catalog (NEO-46). +public static class MasteryCatalogServiceCollectionExtensions +{ + /// Binds and registers and as singletons. + public static IServiceCollection AddMasteryCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var skillCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .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(sp => + new MasteryCatalogRegistry(sp.GetRequiredService())); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs new file mode 100644 index 0000000..8650e74 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// One tier row within a (NEO-46). +public sealed record MasteryTierRow( + int TierIndex, + int RequiredLevel, + IReadOnlyList Branches); diff --git a/server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs b/server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs new file mode 100644 index 0000000..82409af --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// One validated MasteryTrack from content/mastery/*.json (NEO-46). +public sealed record MasteryTrackRow(string SkillId, IReadOnlyList Tiers); diff --git a/server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs b/server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs new file mode 100644 index 0000000..fc9091b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// One validated PerkDef from content/mastery/*.json (NEO-46). +public sealed record PerkDefRow(string Id, string DisplayName, string? EffectKind); diff --git a/server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs b/server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs new file mode 100644 index 0000000..c4ff0d3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs @@ -0,0 +1,31 @@ +namespace NeonSprawl.Server.Game.Mastery; + +/// +/// Prototype Slice 4 mastery gate (NEO-45), mirrored from scripts/validate_content.py +/// PROTOTYPE_SLICE4_SALVAGE_SKILL_ID / _prototype_slice4_gate. +/// +public static class PrototypeSlice4MasteryCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE4_SALVAGE_SKILL_ID. + public const string SalvageSkillId = "salvage"; + + /// Returns a human-readable error if the Slice 4 contract fails, otherwise . + public static string? TryGetSlice4GateError(IReadOnlyList 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 2df659e..f9713e4 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -16,4 +16,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of skills directory}/schemas/skill-def.schema.json (same layout as the repo). /// public string? SkillDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/mastery directory. + /// + public string? MasteryDirectory { get; set; } + + /// + /// Optional override for mastery-catalog.schema.json. + /// When unset, resolved as {parent of mastery directory}/schemas/mastery-catalog.schema.json. + /// + public string? MasteryCatalogSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index d9c6823..df10925 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); +builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); app.MapGet("/", () => Results.Text( diff --git a/server/README.md b/server/README.md index 631e4b2..fdd64bd 100644 --- a/server/README.md +++ b/server/README.md @@ -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/`.