From 898f935b4ffdf52e0d0054c43790da86603311c8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 18:03:30 -0400 Subject: [PATCH 1/4] NEO-46: add implementation plan for mastery catalog boot load. --- docs/plans/NEO-46-implementation-plan.md | 135 +++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/plans/NEO-46-implementation-plan.md diff --git a/docs/plans/NEO-46-implementation-plan.md b/docs/plans/NEO-46-implementation-plan.md new file mode 100644 index 0000000..2607430 --- /dev/null +++ b/docs/plans/NEO-46-implementation-plan.md @@ -0,0 +1,135 @@ +# NEO-46 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-46 | +| **Title** | E2.M3: Server loads mastery catalog at startup (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-46/e2m3-server-loads-mastery-catalog-at-startup-fail-fast | +| **Module** | [E2.M3 — MasteryAndPerkUnlocks](../decomposition/modules/E2_M3_MasteryAndPerkUnlocks.md) · Epic 2 Slice 4 | +| **Branch** | `NEO-46-mastery-catalog-startup-load` | +| **Blocked by** | [NEO-45](https://linear.app/neon-sprawl/issue/NEO-45) (content + CI) — **Done** on `main` | + +## Kickoff clarifications + +| Topic | Question | Answer | +|--------|----------|--------| +| **Code location** | `Game/Skills/` vs `Game/Mastery/`? | **`Game/Mastery/`** — E2.M3 boundary; NEO-47+ (unlock engine, `PerkState`, HTTP) stays cohesive. | +| **`tierIndex` gate** | Enforce uniqueness + sequential 1..N at server boot? | **Yes** — stricter than CI today; protects NEO-47 `PerkState` keys. | +| **Validation strategy** | C# in-process vs subprocess? | **In-process C#** mirroring [`scripts/validate_content.py`](../../scripts/validate_content.py) (NEO-34 precedent). | +| **NEO-45 gate parity** | Which Python constants/rules? | Mirror `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`, Slice 4 single-track gate, unknown `skillId`, duplicate `perkId`, branch-set equality, unreferenced `perks`, branch/perk reference integrity — see [NEO-45 plan](NEO-45-implementation-plan.md) and E2.M3 **NEO-46 handoff**. | + +## Goal, scope, and out-of-scope + +**Goal:** On host startup, load all `content/mastery/*_mastery.json` files validated in CI, build an in-memory mastery catalog, **cross-check every track `skillId` against `ISkillDefinitionRegistry`**, and **refuse to listen** if any file is missing, malformed, or violates prototype gates. Expose read-only **`IMasteryCatalogRegistry`** for NEO-47+. + +**In scope (from Linear):** + +- Fail-fast boot load for mastery catalog(s) under agreed content path. +- `IMasteryCatalogRegistry` resolves tracks by `skillId` and perk metadata by `perkId`. +- Cross-check: every catalog `skillId` exists in `ISkillDefinitionRegistry`. +- Unit tests (AAA) for loader happy path + duplicate id / unknown skill deny. +- Host-level test: valid repo catalog → `WebApplicationFactory` starts; invalid fixture → startup fails with actionable message. + +**Out of scope (from Linear):** + +- Per-player perk state, unlock engine, HTTP (NEO-47–49). +- Client HUD. +- Applying `effectKind` gameplay modifiers. + +## 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. + +## Technical approach + +1. **Configuration:** Extend [`ContentPathsOptions`](../../server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs) with `MasteryDirectory` and optional `MasteryCatalogSchemaPath`. When unset, discover `content/mastery` by walking ancestors of `AppContext.BaseDirectory` (same pattern as `content/skills`). Schema default: `{parent of mastery dir}/schemas/mastery-catalog.schema.json`. +2. **Path resolution:** Add `MasteryCatalogPathResolution` under `Game/Mastery/` (mirror `SkillCatalogPathResolution`). +3. **Loader (`MasteryCatalogLoader`):** Enumerate `*_mastery.json` (top-level only, sorted). Per file: + - Parse JSON; validate whole document against `mastery-catalog.schema.json` via **JsonSchema.Net** (already on server from NEO-34). + - Post-schema gates aligned with [`validate_content.py`](../../scripts/validate_content.py) `_validate_mastery_catalogs` + `_prototype_slice4_gate`: + - `perks` map key == `PerkDef.id` + - Global duplicate `perkId` across files + - Global duplicate branch `perkIds` reference (one reference site per perk id catalog-wide) + - Unknown `perkIds` references; unreferenced `perks` entries + - Unknown `skillId` (against skill ids passed in from `ISkillDefinitionRegistry` / loaded skill catalog) + - Per-tier: duplicate `branchId`; `requiredLevel` strictly increasing; tier *N* `branchId` set equals tier *N−1* + - **Slice 4:** exactly one track across all files; sole track `skillId` == `salvage` (`PrototypeSlice4MasteryCatalogRules`, constant cross-referenced to Python `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID`) + - **Server-only (kickoff):** per track, `tierIndex` values unique and exactly `{1..N}` with no gaps (schema allows gaps; prototype catalog is 1..2). +4. **Models:** Immutable POCOs — `MasteryCatalog`, `MasteryTrackRow`, `MasteryTierRow`, `MasteryBranchRow`, `PerkDefRow` — enough for registry lookups and logging. +5. **Registry:** `IMasteryCatalogRegistry` with `TryGetTrack(string? skillId, out MasteryTrackRow?)`, `TryGetPerk(string? perkId, out PerkDefRow?)`, `GetTracksInSkillIdOrder()`, `GetPerksInIdOrder()` (or equivalent; name flexible). +6. **DI:** `AddMasteryCatalog(IServiceCollection, IConfiguration)` registers `MasteryCatalog` + `IMasteryCatalogRegistry`; loader receives skill ids from `SkillDefinitionCatalog` (resolve skill catalog first in factory delegate). **Eager resolve** in `Program.cs` after skill catalog (same as NEO-34). +7. **Logging:** Information log with resolved mastery directory, file count, track count, perk count. +8. **Docs:** `server/README.md` — `Content:MasteryDirectory` / schema override; note server `tierIndex` gate. Optional one-line pointer in E2.M3 module if implementation touches handoff section (only if needed for accuracy). + +### Gate constant parity (C# ↔ Python) + +| Rule | Python | C# (planned) | +|------|--------|----------------| +| Slice 4 sole track skill | `PROTOTYPE_SLICE4_SALVAGE_SKILL_ID` | `PrototypeSlice4MasteryCatalogRules.SalvageSkillId` | +| Slice 4 track count | `_prototype_slice4_gate` | `TryGetSlice4GateError(trackSkillIds)` | + +Comment in C# constants: `// Keep in sync with scripts/validate_content.py`. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Mastery/ContentPathsMasteryExtensions.cs` | Optional: mastery path helpers if not folded into shared resolution type. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs` | Discover / resolve `content/mastery` and schema paths. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs` | File I/O, schema validation, post-schema gates, skill cross-check. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs` | Immutable load result (tracks by `skillId`, perks by id). | +| `server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs` | DTO for one track after validation. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs` | Tier row (`tierIndex`, `requiredLevel`, branches). | +| `server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs` | Branch row (`branchId`, `displayName`, `perkIds`). | +| `server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs` | Perk metadata row. | +| `server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs` | Slice 4 gate (single salvage track). | +| `server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs` | Read-only lookup contract for game code. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs` | Adapter over `MasteryCatalog`. | +| `server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs` | `AddMasteryCatalog` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs` | Repo schema / fixture path discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs` | Loader unit tests (happy + failure modes). | +| `server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs` | Registry lookup tests + prototype fixture parity. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `MasteryDirectory`, `MasteryCatalogSchemaPath`. | +| `server/NeonSprawl.Server/Program.cs` | Register mastery catalog; eager-resolve after skill catalog. | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Set `Content:MasteryDirectory` (discover repo `content/mastery`) so host tests load real prototype catalog. | +| `server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs` | Same mastery path override as in-memory factory (if present). | +| `server/README.md` | Document mastery content paths and fail-fast behavior. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E2.M3 row: note NEO-46 server load when landed (during implementation). | + +## Tests + +| Test file | Coverage | +|-----------|----------| +| `MasteryCatalogLoaderTests.cs` | **Happy:** temp dir with copy of repo schema + minimal valid catalog matching prototype salvage shape → load succeeds, track/perk counts. **Deny:** malformed JSON; schema violation; unknown `skillId` (skill catalog stub with only `refine`); duplicate `perkId` across files; duplicate global `perkIds` reference; orphan `perks` entry; Slice 4 wrong track count / wrong `skillId`; **`tierIndex`** gap or duplicate. Assert `InvalidOperationException` messages include file path. | +| `MasteryCatalogRegistryTests.cs` | `TryGetTrack("salvage")`, `TryGetPerk("salvage_scrap_efficiency_1")`; unknown ids return false; ordered enumeration. | +| `MasteryCatalogLoaderTests.cs` (host) | `InMemoryWebApplicationFactory` + `/health` + DI `IMasteryCatalogRegistry` resolves salvage track from repo content. | +| `MasteryCatalogLoaderTests.cs` (host negative) | Invalid mastery directory override → host fails startup (mirror `SkillDefinitionCatalogLoaderTests` host case). | + +All new `[Fact]` methods use AAA with `// Arrange` / `// Act` / `// Assert` labels per [csharp-style](../../.cursor/rules/csharp-style.md). + +## Open questions / risks + +- **C# vs Python drift:** Mitigate with shared constant names documented in both places; optional follow-up to add `tierIndex` gate to `validate_content.py` (not required for NEO-46). +- **Test discovery:** `dotnet test` from `server/` must find `content/mastery` via same ancestor walk as skills; verify in CI. +- **Load order:** Mastery loader must run after skill catalog is available in DI (factory dependency on `SkillDefinitionCatalog`). + +None blocking after kickoff clarifications. + +## Decisions + +| Decision | Rationale | +|----------|-----------| +| `Game/Mastery/` folder | User-approved kickoff; separates E2.M3 from E2.M1 skill catalog. | +| `tierIndex` 1..N gate at server boot | User-approved kickoff; protects NEO-47 persistence keys. | +| In-process C# validation | NEO-34 precedent; no Python on server images. | +| Cross-check skill ids from loaded `SkillDefinitionCatalog` | Linear AC; single source of truth with E2.M1. | From 174c390aa4dded03197679ad90ee54313ffc3acd Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 18:06:50 -0400 Subject: [PATCH 2/4] 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. --- .../Health after mastery catalog load.bru | 25 ++ .../mastery-catalog/folder.bru | 3 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-46-implementation-plan.md | 8 +- .../Game/Mastery/MasteryCatalogLoaderTests.cs | 252 ++++++++++++ .../Mastery/MasteryCatalogRegistryTests.cs | 89 +++++ .../Game/Mastery/MasteryCatalogTestPaths.cs | 17 + .../PostgresWebApplicationFactory.cs | 5 + .../InMemoryWebApplicationFactory.cs | 5 + .../Game/Mastery/IMasteryCatalogRegistry.cs | 21 + .../Game/Mastery/MasteryBranchRow.cs | 7 + .../Game/Mastery/MasteryCatalog.cs | 35 ++ .../Game/Mastery/MasteryCatalogLoader.cs | 358 ++++++++++++++++++ .../Mastery/MasteryCatalogPathResolution.cs | 60 +++ .../Game/Mastery/MasteryCatalogRegistry.cs | 37 ++ ...steryCatalogServiceCollectionExtensions.cs | 42 ++ .../Game/Mastery/MasteryTierRow.cs | 7 + .../Game/Mastery/MasteryTrackRow.cs | 4 + .../Game/Mastery/PerkDefRow.cs | 4 + .../PrototypeSlice4MasteryCatalogRules.cs | 31 ++ .../Game/Skills/ContentPathsOptions.cs | 12 + server/NeonSprawl.Server/Program.cs | 3 + server/README.md | 13 + 23 files changed, 1035 insertions(+), 5 deletions(-) create mode 100644 bruno/neon-sprawl-server/mastery-catalog/Health after mastery catalog load.bru create mode 100644 bruno/neon-sprawl-server/mastery-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogRegistryTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Mastery/MasteryCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/IMasteryCatalogRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryBranchRow.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryCatalogRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryTierRow.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/MasteryTrackRow.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PerkDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Mastery/PrototypeSlice4MasteryCatalogRules.cs 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/`. From 07fc566e2c6a8bcb05d1642849c82af78fc937f4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 18:12:39 -0400 Subject: [PATCH 3/4] NEO-46: address code review follow-up. Add loader deny-path and registry enumeration tests, tierIndex 1..N gate in validate_content.py, E2.M3 handoff doc update, and review strikethroughs. --- .../modules/E2_M3_MasteryAndPerkUnlocks.md | 4 +- docs/reviews/2026-05-17-NEO-46.md | 59 ++++ scripts/validate_content.py | 31 ++ .../Game/Mastery/MasteryCatalogLoaderTests.cs | 265 ++++++++++++++---- .../Mastery/MasteryCatalogRegistryTests.cs | 53 +++- 5 files changed, 353 insertions(+), 59 deletions(-) create mode 100644 docs/reviews/2026-05-17-NEO-46.md 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); From 28bafaf77d58e18b467c031b00edf5ff54ba2e6c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 18:22:46 -0400 Subject: [PATCH 4/4] chore: enforce commit-as-you-go via rule, AGENTS.md, and stop hook. Add alwaysApply commit-as-you-go checklist, Critical git commits in AGENTS.md, and a stop hook that follow-ups when NEO-* branches end with a dirty tree. --- .cursor/hooks.json | 11 +++++ .cursor/hooks/check-story-branch-clean.sh | 56 +++++++++++++++++++++++ .cursor/rules/commit-and-review.md | 2 + .cursor/rules/commit-as-you-go.md | 34 ++++++++++++++ .cursor/rules/git-workflow.md | 2 +- AGENTS.md | 8 ++++ 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 .cursor/hooks.json create mode 100755 .cursor/hooks/check-story-branch-clean.sh create mode 100644 .cursor/rules/commit-as-you-go.md diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..801517c --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "hooks": { + "stop": [ + { + "command": ".cursor/hooks/check-story-branch-clean.sh", + "loop_limit": 2 + } + ] + } +} diff --git a/.cursor/hooks/check-story-branch-clean.sh b/.cursor/hooks/check-story-branch-clean.sh new file mode 100755 index 0000000..67bf440 --- /dev/null +++ b/.cursor/hooks/check-story-branch-clean.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Cursor stop hook (Neon Sprawl): on NEO-* story branches, nag when the working tree is dirty. +# See .cursor/rules/commit-as-you-go.md and commit-and-review.md. +set -euo pipefail + +input=$(cat) + +status="completed" +if command -v jq >/dev/null 2>&1; then + status=$(echo "$input" | jq -r '.status // "completed"') +fi + +if [[ "$status" != "completed" ]]; then + exit 0 +fi + +root="${CURSOR_PROJECT_DIR:-}" +if [[ -z "$root" || ! -d "$root" ]]; then + root=$(pwd) +fi +if command -v jq >/dev/null 2>&1; then + for key in workspace_roots workspace_root cwd project_path; do + candidate=$(echo "$input" | jq -r --arg k "$key" 'if .[$k] then (if (.[$k] | type) == "array" then .[$k][0] else .[$k] end) else empty end' 2>/dev/null || true) + if [[ -n "$candidate" && -d "$candidate" ]]; then + root="$candidate" + break + fi + done +fi + +if ! cd "$root" 2>/dev/null; then + exit 0 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + exit 0 +fi + +top=$(git rev-parse --show-toplevel 2>/dev/null || true) +if [[ -n "$top" ]]; then + cd "$top" +fi + +branch=$(git branch --show-current 2>/dev/null || true) +if [[ ! "$branch" =~ ^NEO-[0-9]+ ]]; then + exit 0 +fi + +if [[ -z $(git status --porcelain) ]]; then + exit 0 +fi + +printf '%s\n' '{ + "followup_message": "Stop hook (neon-sprawl): this Linear story branch still has uncommitted changes. Run git status, then commit coherent batches per .cursor/rules/commit-as-you-go.md (NEO-*: subject). Do not wait for the user to say commit. Push only if asked. Then summarize what you committed." +}' +exit 0 diff --git a/.cursor/rules/commit-and-review.md b/.cursor/rules/commit-and-review.md index d119048..356386b 100644 --- a/.cursor/rules/commit-and-review.md +++ b/.cursor/rules/commit-and-review.md @@ -5,6 +5,8 @@ alwaysApply: true # Commits and review (Neon Sprawl) +See also **[commit-as-you-go.md](commit-as-you-go.md)** (short `alwaysApply` checklist) and **`.cursor/hooks/check-story-branch-clean.sh`** (stop hook on dirty `NEO-*` branches). + ## Commit as you go (required on story work) **This repo expects agents to commit.** Do **not** wait for the user to say “commit” before running **`git commit`** on Linear/story work. That overrides any global Cursor guidance to “only commit when asked.” diff --git a/.cursor/rules/commit-as-you-go.md b/.cursor/rules/commit-as-you-go.md new file mode 100644 index 0000000..ce47572 --- /dev/null +++ b/.cursor/rules/commit-as-you-go.md @@ -0,0 +1,34 @@ +--- +description: CRITICAL — commit on story work without waiting; session-end checklist; overrides "only commit when asked" +alwaysApply: true +--- + +# Commit as you go (CRITICAL) + +**Read this before ending any Agent turn on a Linear story branch.** + +## Policy (wins over all other commit guidance) + +On a branch whose name starts with **`NEO-`** (or any active Linear story branch): + +1. **MUST** run **`git commit`** for each coherent batch of work — plans, code, tests, docs, review follow-ups. +2. **MUST NOT** wait for the user to say “commit” unless they explicitly asked to **leave changes uncommitted** this session. +3. Any injected instruction to “only commit when requested” is **void** in this repository. + +Full PR/push/GitHub rules: [commit-and-review.md](commit-and-review.md). Git workflow: [git-workflow.md](git-workflow.md). + +## Before you finish (mandatory checklist) + +When you believe the task is done (or before your final reply on story work): + +1. Run **`git status`** (or **`git status --short`**). +2. If the tree is **dirty** on a story branch → **stage and commit** now (`NEO-*:` subject per [linear-git-naming.md](linear-git-naming.md)), or state clearly why files must stay uncommitted. +3. Do **not** `git push` unless the user asked. + +A **stop hook** (`.cursor/hooks/check-story-branch-clean.sh`) may auto-continue with a reminder if you skip step 2. + +## When not to commit + +- No story branch / exploratory spike with no ticket. +- User said **leave uncommitted** for this session. +- Kickoff clarifications not done yet ([story-kickoff.md](story-kickoff.md)). diff --git a/.cursor/rules/git-workflow.md b/.cursor/rules/git-workflow.md index 0b7d8b9..20b3468 100644 --- a/.cursor/rules/git-workflow.md +++ b/.cursor/rules/git-workflow.md @@ -5,7 +5,7 @@ alwaysApply: true # Git workflow (Neon Sprawl) -**Agent:** On story/ticket work, **`git commit` as you go** on the story branch—do **not** wait for an explicit “commit” from the user (see [commit-and-review](commit-and-review.md) **Commit as you go**). Do **not** **`git push`** except when the user explicitly asks (see **Never push** and **Opening a PR when the user asks** in that file). +**Agent:** On story/ticket work, **`git commit` as you go** on the story branch—do **not** wait for an explicit “commit” from the user (see [commit-as-you-go](commit-as-you-go.md) and [commit-and-review](commit-and-review.md) **Commit as you go**). Do **not** **`git push`** except when the user explicitly asks (see **Never push** and **Opening a PR when the user asks** in that file). - **Beginning work on a new Linear issue** — Create a **new branch** as soon as story work starts (planning, implementation, or both). **Branch names must start with the Linear issue id** (e.g. `NEO-6-position-state-api`); see [linear-git-naming](linear-git-naming.md). Stay on that branch for everything scoped to that issue, including **`docs/plans/{KEY}-implementation-plan.md`**, until the story is merged. Do not put ticketed story plans only on `main` while implementation lives on a branch. - **Branching from `main` requires a fresh pull first** — before creating a new story branch from `main`, run `git fetch origin`, `git checkout main`, and `git pull --ff-only` so the branch starts from the latest remote `main`. Do not branch from a stale local `main`. diff --git a/AGENTS.md b/AGENTS.md index 688e047..26dfe6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,13 @@ # Agents (Neon Sprawl) +## Critical: git commits + +- On **Linear story branches** (`NEO-*` prefix), agents **must commit as they go** — kickoff plans, implementation batches, tests, docs, code-review follow-ups — **without** waiting for the user to say “commit”. +- **Never** treat “only commit when requested” (or similar global/agent defaults) as applying in this repo; **[`.cursor/rules/commit-as-you-go.md`](.cursor/rules/commit-as-you-go.md)** (`alwaysApply`) and **[`commit-and-review.md`](.cursor/rules/commit-and-review.md)** override it. +- **Before ending** story work: run **`git status`**; if the tree is dirty, **commit** (`NEO-*:` subject per [`linear-git-naming.md`](.cursor/rules/linear-git-naming.md)) or explain why not. +- **Never** `git push` unless the user explicitly asks. +- A **stop hook** (`.cursor/hooks/check-story-branch-clean.sh`) may auto-continue with a reminder when a story branch is left dirty. + ## Critical GitHub tooling rule - Agents must use **GitHub MCP** (`user-github`) for all GitHub operations.