diff --git a/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md b/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md index c4ddeb5..159060d 100644 --- a/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md +++ b/docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md @@ -8,7 +8,7 @@ | **Epic** | [Epic 2 — Skills and Progression Framework](../epics/epic_02_skills_and_progression.md) | | **Linear (Epic 2 project)** | [Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6) | | **Stage target** | Pre-production | -| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed) | +| **Status** | In Progress (see [dependency register](module_dependency_register.md); **NEO-45** catalog + CI landed; **NEO-46** server fail-fast load landed) | ## Linear backlog (Epic 2 Slice 4 — E2.M3) @@ -75,7 +75,7 @@ Each catalog file has top-level **`schemaVersion`**, a **`perks`** map (`perkId` ## NEO-46 handoff (server catalog load) -When [NEO-46](https://linear.app/neon-sprawl/issue/NEO-46) kicks off, mirror the Python CI gates documented in [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries). Consider **`tierIndex`** uniqueness and sequential 1..N per track at server boot (schema allows gaps today). +**Landed ([NEO-46](https://linear.app/neon-sprawl/issue/NEO-46)):** Server boot loads `content/mastery/*_mastery.json` fail-fast under `Game/Mastery/`, mirroring Python CI gates from [NEO-45 implementation plan](../../plans/NEO-45-implementation-plan.md) (`PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, exactly one track, unknown `skillId`, duplicate `perkId`, branch-set equality tier-to-tier, unreferenced `perks` entries) plus cross-check against `ISkillDefinitionRegistry`. **`tierIndex`** uniqueness and sequential **1..N** per track are enforced at server boot and in **`validate_content.py`** (protects NEO-47 `PerkState` keys). Read-only **`IMasteryCatalogRegistry`** is registered for NEO-47+. ## Prototype Slice 4 freeze — salvage flagship ([NEO-45](https://linear.app/neon-sprawl/issue/NEO-45)) diff --git a/docs/reviews/2026-05-17-NEO-46.md b/docs/reviews/2026-05-17-NEO-46.md new file mode 100644 index 0000000..cddeec3 --- /dev/null +++ b/docs/reviews/2026-05-17-NEO-46.md @@ -0,0 +1,59 @@ +# Code review — NEO-46 mastery catalog startup load + +**Date:** 2026-05-17 +**Scope:** Branch `NEO-46-mastery-catalog-startup-load` · commits `898f935`–`174c390` vs `main` +**Base:** `main` + +**Follow-up:** Addressed review suggestions (tests, `validate_content.py` `tierIndex` gate, E2.M3 module doc). + +## Verdict + +**Approve with nits** + +## Summary + +This change adds fail-fast loading of `content/mastery/*_mastery.json` at server startup, mirroring the NEO-34 skill-catalog pattern: path discovery, JsonSchema.Net validation, post-schema gates aligned with `scripts/validate_content.py`, a server-only `tierIndex` 1..N gate, cross-check against loaded skill ids, and a read-only `IMasteryCatalogRegistry` for NEO-47+. DI wires mastery load after `SkillDefinitionCatalog`; `Program.cs` eagerly resolves both catalogs before mapping routes. Tests cover the loader happy path, several deny paths, and host startup success/failure; all 13 mastery-related tests pass locally. Overall risk is low: read-only content load, no player state or HTTP surface yet. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-46-implementation-plan.md`](../plans/NEO-46-implementation-plan.md) | **Matches** — `Game/Mastery/`, config keys, loader gates, registry API, eager resolve, README, alignment table, acceptance checklist marked complete. | +| [`docs/decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md`](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) | **Matches** — Status and NEO-46 handoff updated after follow-up. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E2.M3 row updated for NEO-46 landed. | +| [`docs/plans/NEO-45-implementation-plan.md`](../plans/NEO-45-implementation-plan.md) | **Matches** — Slice 4 constant/gate parity via `PrototypeSlice4MasteryCatalogRules`. | +| [`scripts/validate_content.py`](../../scripts/validate_content.py) | **Matches** — `tierIndex` 1..N gate added in CI (follow-up). | + +Register/tracking: E2.M3 alignment row is updated; module doc refreshed (follow-up). + +## Blocking issues + +None. + +## Suggestions + +1. ~~**Test coverage vs plan table** — The implementation plan lists deny cases for malformed JSON, schema violation, duplicate global `perkIds` reference, branch-set mismatch, non-increasing `requiredLevel`, Slice 4 wrong `skillId`, and duplicate `tierIndex`. Current tests cover unknown skill, duplicate perk across files, orphan perk, Slice 4 multi-track, `tierIndex` gap, and host negative startup; adding 2–3 focused loader tests (e.g. duplicate branch perk reference, `tierIndex` duplicate, wrong sole `skillId` for Slice 4) would close the gap and guard C#/Python drift.~~ **Done.** Added loader tests for malformed JSON, schema violation, duplicate perk reference, branch-set mismatch, flat `requiredLevel`, wrong Slice 4 sole `skillId`, and duplicate `tierIndex`. +2. ~~**Registry enumeration** — `GetTracksInSkillIdOrder()` / `GetPerksInIdOrder()` are part of the contract but untested; a small unit test on a constructed catalog would lock ordering for NEO-47 consumers.~~ **Done.** `MasteryCatalogRegistryTests` covers both enumeration methods. +3. ~~**E2.M3 module doc** — Update the **NEO-46 handoff** bullet to state that `tierIndex` uniqueness and sequential 1..N are enforced at server boot (not merely “consider”), and refresh the module **Status** line to mention NEO-46 (optional but keeps decomposition docs authoritative).~~ **Done.** `E2_M3_MasteryAndPerkUnlocks.md` updated. +4. ~~**CI follow-up (already in plan risks)** — Consider adding the `tierIndex` gate to `validate_content.py` so PR CI and server boot cannot diverge on catalog edits.~~ **Done.** `_tier_index_gate` in `validate_content.py` mirrors `MasteryCatalogLoader.TryGetTierIndexGateError`. + +## Nits + +- ~~Nit: `MasteryCatalogLoaderTests.CreateTempContentLayout()` leaves temp directories behind; consider `try/finally` with `Directory.Delete(..., recursive: true)` or `IDisposable` fixture (same pattern as other catalog tests if they exist).~~ **Done.** `TempContentLayout` implements `IDisposable`. +- ~~Nit: Host startup is exercised in both `MasteryCatalogLoaderTests` and `MasteryCatalogRegistryTests`; consolidating to one host integration test would reduce duplication (not required for merge).~~ **Done.** Host success path lives only in `MasteryCatalogRegistryTests`; loader tests keep host negative startup only. +- Nit: Bruno `mastery-catalog` folder only hits `/health` — appropriate for NEO-46 scope; NEO-48 will own real mastery HTTP Bruno coverage. **Deferred** — out of scope for NEO-46. + +## Verification + +```bash +# From repo root +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~MasteryCatalog" + +# Full server test suite (recommended before merge) +dotnet test NeonSprawl.sln + +# Content gate (should stay green with repo catalog) +python3 scripts/validate_content.py +``` + +Manual: start server from clone with default content discovery; confirm Information log lists mastery directory, track count `1`, perk count `2` for prototype salvage catalog. diff --git a/scripts/validate_content.py b/scripts/validate_content.py index e001328..97b9db4 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -5,6 +5,7 @@ Validates: - skill catalogs: content/skills/*_skills.json rows vs content/schemas/skill-def.schema.json - level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json - mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json + (post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot — NEO-46) """ from __future__ import annotations @@ -51,6 +52,26 @@ def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, s return None +def _tier_index_gate(rel: str, track_index: int, tier_index_values: list[int]) -> str | None: + """Keep in sync with MasteryCatalogLoader.TryGetTierIndexGateError (NEO-46).""" + if not tier_index_values: + return None + seen: set[int] = set() + for value in tier_index_values: + if value in seen: + return f"error: {rel}: tracks[{track_index}] duplicate tierIndex {value}" + seen.add(value) + expected = set(range(1, len(tier_index_values) + 1)) + if seen != expected: + sorted_vals = ", ".join(str(v) for v in sorted(seen)) + n = len(tier_index_values) + return ( + f"error: {rel}: tracks[{track_index}] tierIndex values must be unique and " + f"sequential 1..{n}, got [{sorted_vals}]" + ) + return None + + def _validate_mastery_catalogs( *, mastery_files: list[Path], @@ -130,11 +151,16 @@ def _validate_mastery_catalogs( prev_required_level = 0 prev_tier_branch_ids: frozenset[str] | None = None + tier_index_values: list[int] = [] for tier_i, tier in enumerate(tiers): if not isinstance(tier, dict): continue + tier_index = tier.get("tierIndex") + if isinstance(tier_index, int): + tier_index_values.append(tier_index) + required_level = tier.get("requiredLevel") if isinstance(required_level, int) and required_level <= prev_required_level: print( @@ -203,6 +229,11 @@ def _validate_mastery_catalogs( if tier_branch_ids: prev_tier_branch_ids = tier_branch_set + tier_index_err = _tier_index_gate(rel, ti, tier_index_values) + if tier_index_err: + print(tier_index_err, file=sys.stderr) + errors += 1 + for perk_key in perks: if isinstance(perk_key, str) and perk_key not in referenced_perk_ids_in_file: print( diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs index 6b74c1a..ca09f69 100644 --- a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs @@ -2,10 +2,8 @@ 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; @@ -59,57 +57,128 @@ public class MasteryCatalogLoaderTests } """; - private static (string Root, string MasteryDir, string SchemaPath) CreateTempContentLayout() + private sealed class TempContentLayout : IDisposable { - 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); + public string MasteryDir { get; } + public string SchemaPath { get; } + private readonly string _root; + + private TempContentLayout(string root, string masteryDir, string schemaPath) + { + _root = root; + MasteryDir = masteryDir; + SchemaPath = schemaPath; + } + + public static TempContentLayout Create() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-masterycat-"); + var masteryDir = Path.Combine(root.FullName, "content", "mastery"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(masteryDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "mastery-catalog.schema.json"); + File.Copy(MasteryCatalogTestPaths.DiscoverRepoMasteryCatalogSchemaPath(), schemaPath, overwrite: true); + return new TempContentLayout(root.FullName, masteryDir, schemaPath); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_root)) + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup for parallel test runs. + } + } } [Fact] public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() { // Arrange - var (_, masteryDir, schemaPath) = CreateTempContentLayout(); - File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); + using var layout = TempContentLayout.Create(); + File.WriteAllText( + Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"), + ValidSalvageMasteryJson, + Encoding.UTF8); // Act - var catalog = MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance); + var catalog = MasteryCatalogLoader.Load( + layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance); // Assert Assert.Equal(1, catalog.TrackCount); Assert.Equal(2, catalog.PerkCount); Assert.True(catalog.TracksBySkillId.ContainsKey("salvage")); } + [Fact] + public void Load_ShouldThrow_WhenJsonIsMalformed() + { + // Arrange + using var layout = TempContentLayout.Create(); + File.WriteAllText(Path.Combine(layout.MasteryDir, "bad_mastery.json"), "{ not json", Encoding.UTF8); + // Act + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_mastery.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("invalid JSON", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenTracksIsNotArray() { // Arrange - var (_, masteryDir, schemaPath) = CreateTempContentLayout(); - File.WriteAllText(Path.Combine(masteryDir, "bad_mastery.json"), """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", Encoding.UTF8); + using var layout = TempContentLayout.Create(); + File.WriteAllText( + Path.Combine(layout.MasteryDir, "bad_mastery.json"), + """{"schemaVersion":1,"perks":{},"tracks":"nope"}""", + Encoding.UTF8); // Act var ex = Record.Exception(() => - MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); + MasteryCatalogLoader.Load(layout.MasteryDir, layout.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_WhenRowViolatesSchema() + { + // Arrange + using var layout = TempContentLayout.Create(); + var bad = ValidSalvageMasteryJson.Replace( + "\"displayName\": \"Scrap Sifter\"", + "\"displayName\": \"\"", + StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "schema_bad_mastery.json"), bad, Encoding.UTF8); + // Act + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("schema_bad_mastery.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("Mastery catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenUnknownSkillId() { // Arrange - var (_, masteryDir, schemaPath) = CreateTempContentLayout(); + using var layout = TempContentLayout.Create(); var onlyRefineSkills = FrozenSet.ToFrozenSet(["refine"], StringComparer.Ordinal); - File.WriteAllText(Path.Combine(masteryDir, "prototype_salvage_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); + File.WriteAllText( + Path.Combine(layout.MasteryDir, "prototype_salvage_mastery.json"), + ValidSalvageMasteryJson, + Encoding.UTF8); // Act var ex = Record.Exception(() => - MasteryCatalogLoader.Load(masteryDir, schemaPath, onlyRefineSkills, NullLogger.Instance)); + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, onlyRefineSkills, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); Assert.Contains("not a known SkillDef id", ioe.Message, StringComparison.Ordinal); @@ -120,41 +189,94 @@ public class MasteryCatalogLoaderTests 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); + using var layout = TempContentLayout.Create(); + File.WriteAllText(Path.Combine(layout.MasteryDir, "a_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); + File.WriteAllText(Path.Combine(layout.MasteryDir, "b_mastery.json"), ValidSalvageMasteryJson, Encoding.UTF8); // Act var ex = Record.Exception(() => - MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); + MasteryCatalogLoader.Load(layout.MasteryDir, layout.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_WhenDuplicatePerkIdReferenceAcrossBranches() + { + // Arrange + using var layout = TempContentLayout.Create(); + var duplicateRef = ValidSalvageMasteryJson.Replace( + "\"perkIds\": [\"salvage_bulk_haul_1\"]", + "\"perkIds\": [\"salvage_scrap_efficiency_1\"]", + StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_ref_mastery.json"), duplicateRef, Encoding.UTF8); + // Act + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate perk id reference", ioe.Message, StringComparison.Ordinal); + Assert.Contains("salvage_scrap_efficiency_1", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenUnreferencedPerkInMap() { // Arrange - var (_, masteryDir, schemaPath) = CreateTempContentLayout(); + using var layout = TempContentLayout.Create(); var orphanPerk = ValidSalvageMasteryJson.Replace( "\"salvage_bulk_haul_1\":", "\"salvage_orphan_1\": { \"id\": \"salvage_orphan_1\", \"displayName\": \"Orphan\" },\n \"salvage_bulk_haul_1\":", StringComparison.Ordinal); - File.WriteAllText(Path.Combine(masteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8); + File.WriteAllText(Path.Combine(layout.MasteryDir, "orphan_mastery.json"), orphanPerk, Encoding.UTF8); // Act var ex = Record.Exception(() => - MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); + MasteryCatalogLoader.Load(layout.MasteryDir, layout.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_WhenBranchIdSetDiffersBetweenTiers() + { + // Arrange + using var layout = TempContentLayout.Create(); + var mismatch = ValidSalvageMasteryJson.Replace( + "\"branchId\": \"bulk_haul\", \"perkIds\": [\"salvage_bulk_haul_1\"]", + "\"branchId\": \"other_branch\", \"perkIds\": [\"salvage_bulk_haul_1\"]", + StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "branch_mismatch_mastery.json"), mismatch, Encoding.UTF8); + // Act + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("branchId set", ioe.Message, StringComparison.Ordinal); + Assert.Contains("must match previous tier", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRequiredLevelNotStrictlyIncreasing() + { + // Arrange + using var layout = TempContentLayout.Create(); + var flatLevel = ValidSalvageMasteryJson.Replace("\"requiredLevel\": 4", "\"requiredLevel\": 2", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "flat_level_mastery.json"), flatLevel, Encoding.UTF8); + // Act + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("requiredLevel must be strictly greater", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenSlice4TrackCountWrong() { // Arrange - var (_, masteryDir, schemaPath) = CreateTempContentLayout(); + using var layout = TempContentLayout.Create(); const string twoTracksJson = """ { "schemaVersion": 1, @@ -189,43 +311,59 @@ public class MasteryCatalogLoaderTests ] } """; - File.WriteAllText(Path.Combine(masteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8); + File.WriteAllText(Path.Combine(layout.MasteryDir, "two_tracks_mastery.json"), twoTracksJson, Encoding.UTF8); // Act var ex = Record.Exception(() => - MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); + MasteryCatalogLoader.Load(layout.MasteryDir, layout.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() + public void Load_ShouldThrow_WhenSlice4SoleTrackSkillIdWrong() { // 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); + using var layout = TempContentLayout.Create(); + var refineTrack = ValidSalvageMasteryJson.Replace("\"skillId\": \"salvage\"", "\"skillId\": \"refine\"", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "refine_track_mastery.json"), refineTrack, Encoding.UTF8); // Act var ex = Record.Exception(() => - MasteryCatalogLoader.Load(masteryDir, schemaPath, PrototypeSkillIds, NullLogger.Instance)); + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert var ioe = Assert.IsType(ex); - Assert.Contains("tierIndex values must be unique and sequential", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype Slice 4 expects the sole track skillId", ioe.Message, StringComparison.Ordinal); + Assert.Contains("refine", ioe.Message, StringComparison.Ordinal); } [Fact] - public async Task Host_ShouldResolveMasteryCatalogFromDi_WhenStartupSucceeds() + public void Load_ShouldThrow_WhenTierIndexDuplicate() { // Arrange - await using var factory = new InMemoryWebApplicationFactory(); - using var client = factory.CreateClient(); + using var layout = TempContentLayout.Create(); + var dupTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 1", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "dup_tier_mastery.json"), dupTier, Encoding.UTF8); // Act - var response = await client.GetAsync("/health"); + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var catalog = factory.Services.GetRequiredService(); - Assert.Equal(1, catalog.TrackCount); - Assert.True(catalog.TracksBySkillId.ContainsKey("salvage")); + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate tierIndex", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenTierIndexHasGap() + { + // Arrange + using var layout = TempContentLayout.Create(); + var gapTier = ValidSalvageMasteryJson.Replace("\"tierIndex\": 2", "\"tierIndex\": 3", StringComparison.Ordinal); + File.WriteAllText(Path.Combine(layout.MasteryDir, "gap_tier_mastery.json"), gapTier, Encoding.UTF8); + // Act + var ex = Record.Exception(() => + MasteryCatalogLoader.Load(layout.MasteryDir, layout.SchemaPath, PrototypeSkillIds, NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("tierIndex values must be unique and sequential", ioe.Message, StringComparison.Ordinal); } [Fact] @@ -233,20 +371,35 @@ public class MasteryCatalogLoaderTests { // 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(() => + try { - using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + Directory.CreateDirectory(badDir); + var skillsDir = SkillCatalogTestPaths.DiscoverRepoSkillsDirectory(); + // Act + var ex = Record.Exception(() => { - b.UseSetting("Content:SkillsDirectory", skillsDir); - b.UseSetting("Content:MasteryDirectory", badDir); + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseSetting("Content:SkillsDirectory", skillsDir); + b.UseSetting("Content:MasteryDirectory", badDir); + }); + factory.CreateClient(); }); - factory.CreateClient(); - }); - // Assert - Assert.NotNull(ex); - Assert.Contains("Mastery catalog validation failed", ex.ToString(), StringComparison.Ordinal); + // Assert + Assert.NotNull(ex); + Assert.Contains("Mastery catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + try + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir, recursive: true); + } + catch (IOException) + { + // Best-effort cleanup. + } + } } } diff --git a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs index adde960..44b47df 100644 --- a/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Microsoft.Extensions.DependencyInjection; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Tests; @@ -67,18 +68,68 @@ public class MasteryCatalogRegistryTests Assert.Equal("gather_yield_modifier", result.EffectKind); } + [Fact] + public void GetTracksInSkillIdOrder_ShouldReturnTracksSortedBySkillId() + { + // Arrange + var refine = new MasteryTrackRow("refine", []); + var salvage = new MasteryTrackRow("salvage", []); + var intrusion = new MasteryTrackRow("intrusion", []); + var catalog = new MasteryCatalog( + "/tmp/mastery", + new Dictionary(StringComparer.Ordinal) + { + ["refine"] = refine, + ["salvage"] = salvage, + ["intrusion"] = intrusion, + }, + new Dictionary(StringComparer.Ordinal), + catalogJsonFileCount: 1); + var registry = CreateRegistryFromCatalog(catalog); + // Act + var tracks = registry.GetTracksInSkillIdOrder(); + // Assert + Assert.Equal(["intrusion", "refine", "salvage"], tracks.Select(t => t.SkillId).ToArray()); + } + + [Fact] + public void GetPerksInIdOrder_ShouldReturnPerksSortedById() + { + // Arrange + var perks = new Dictionary(StringComparer.Ordinal) + { + ["z_perk"] = new("z_perk", "Z", null), + ["a_perk"] = new("a_perk", "A", null), + ["m_perk"] = new("m_perk", "M", null), + }; + var catalog = new MasteryCatalog( + "/tmp/mastery", + new Dictionary(StringComparer.Ordinal), + perks, + catalogJsonFileCount: 1); + var registry = CreateRegistryFromCatalog(catalog); + // Act + var ordered = registry.GetPerksInIdOrder(); + // Assert + Assert.Equal(["a_perk", "m_perk", "z_perk"], ordered.Select(p => p.Id).ToArray()); + } + [Fact] public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() { // Arrange await using var factory = new InMemoryWebApplicationFactory(); using var client = factory.CreateClient(); - _ = await client.GetAsync("/health"); // Act + var response = await client.GetAsync("/health"); var registry = factory.Services.GetRequiredService(); + var catalog = factory.Services.GetRequiredService(); var foundTrack = registry.TryGetTrack("salvage", out var track); var foundPerk = registry.TryGetPerk("salvage_scrap_efficiency_1", out var perk); // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, catalog.TrackCount); + Assert.True(catalog.TracksBySkillId.ContainsKey("salvage")); Assert.True(foundTrack); Assert.NotNull(track); Assert.Equal(2, track.Tiers.Count);