Compare commits
10 Commits
807812148f
...
ef745ae6a7
| Author | SHA1 | Date |
|---|---|---|
|
|
ef745ae6a7 | |
|
|
9da5e7dbf5 | |
|
|
922e81eb0f | |
|
|
1a9088c77c | |
|
|
6344858541 | |
|
|
7b307e503c | |
|
|
9afaad71ca | |
|
|
02cb4d8556 | |
|
|
583201cc7c | |
|
|
66150ea224 |
|
|
@ -9,3 +9,13 @@ get {
|
|||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("status 200", function () {
|
||||
expect(res.getStatus()).to.equal(200);
|
||||
});
|
||||
|
||||
test("service identity", function () {
|
||||
expect(res.getBody().service).to.equal("NeonSprawl.Server");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with **
|
|||
| [`schemas/`](schemas/) | JSON Schema files (`*.schema.json`) |
|
||||
| [`skills/`](skills/) | Skill catalogs; each row matches [`schemas/skill-def.schema.json`](schemas/skill-def.schema.json) — **stable `id`**, **`allowedXpSourceKinds`** for [E2.M1](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md) / [E2.M2](../docs/decomposition/modules/E2_M2_XpAwardAndLevelEngine.md) |
|
||||
|
||||
**Prototype Slice 1 (NEO-33):** CI expects **exactly three** skill rows with ids **`salvage`**, **`refine`**, **`intrusion`** (gather + process + tech). Each row must declare **`allowedXpSourceKinds`** (non-empty); see [E2.M1 — Designer note: `allowedXpSourceKinds`](../docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md#designer-note-allowedxpsourcekinds-and-grant-call-sites) for what each kind means for grant wiring.
|
||||
|
||||
Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth.
|
||||
|
||||
**Validate locally** (same as PR gate):
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
|
|||
|
||||
## CI (prototype)
|
||||
|
||||
**Skill catalogs:** the repo **PR gate** workflow ([`.github/workflows/pr-gate.yml`](../../../.github/workflows/pr-gate.yml)) runs [`scripts/validate_content.py`](../../../scripts/validate_content.py) on every push and pull request. It validates each object in `content/skills/*.json` under the top-level `skills` array against [`content/schemas/skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) (Python **jsonschema**, Draft 2020-12). Extend the script when additional catalogs and schemas ship.
|
||||
**Skill catalogs:** the repo **PR gate** workflow ([`.github/workflows/pr-gate.yml`](../../../.github/workflows/pr-gate.yml)) runs [`scripts/validate_content.py`](../../../scripts/validate_content.py) on every push and pull request. It validates each object in `content/skills/*.json` under the top-level `skills` array against [`content/schemas/skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) (Python **jsonschema**, Draft 2020-12), rejects **duplicate `id`** across files, and (Slice 1 / [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33)) enforces the **frozen prototype trio** ids plus **gather + tech + (process or make)** category coverage. Extend the script when additional catalogs and schemas ship.
|
||||
|
||||
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.
|
||||
|
||||
|
|
|
|||
|
|
@ -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** | Prototype |
|
||||
| **Status** | Planned (see [dependency register](module_dependency_register.md)) |
|
||||
| **Status** | In Progress (see [dependency register](module_dependency_register.md)) |
|
||||
|
||||
## Linear backlog (Epic 2 Slice 1)
|
||||
|
||||
|
|
@ -54,6 +54,28 @@ Central catalog for **non-combat** skill metadata: **stable IDs**, **categories*
|
|||
|
||||
Add new enum values only with a **schema version** / migration note in [CT.M1](CT_M1_ContentValidationPipeline.md) when CI enforces catalogs.
|
||||
|
||||
### Designer note: allowedXpSourceKinds and grant call sites
|
||||
|
||||
Every `SkillDef` row **must** include a **non-empty** `allowedXpSourceKinds` array (enforced by JSON Schema). Designers use it to agree with engineering **which systems may ever grant XP** to that skill:
|
||||
|
||||
- **`activity`** — wire gather nodes, recipe completions, skill-based world objectives, and similar **primary-loop** payouts; set `XpGrantEvent.sourceKind` (or successor) to `activity` on those code paths.
|
||||
- **`mission_reward`** — wire **explicit** mission/contract hand-ins or scripted bonuses only; not a stand-in for combat encounter XP ([`docs/game-design/progression.md`](../../game-design/progression.md) — mission vs combat).
|
||||
- **`trainer`** / **`book_or_item`** — wire NPC teaching beats and consumable/one-shot grants respectively; do not use for combat kills.
|
||||
|
||||
**Combat encounters award gig XP, not skill XP.** If a grant channel is missing from a skill’s list, [E2.M2](E2_M2_XpAwardAndLevelEngine.md) must reject the grant once implemented—keep catalogs honest so call sites do not drift.
|
||||
|
||||
## Prototype Slice 1 freeze ([NEO-33](https://linear.app/neon-sprawl/issue/NEO-33))
|
||||
|
||||
The **first shipped trio** under `content/skills/*.json` is **frozen** for downstream references (quests, recipes, E2.M2, E3) until a deliberate migration or follow-up issue expands the roster.
|
||||
|
||||
| `SkillDef.id` | `category` | Role |
|
||||
|---------------|------------|------|
|
||||
| **`salvage`** | `gather` | Prototype Gather anchor |
|
||||
| **`refine`** | `process` | Prototype Process anchor (satisfies “Process or Make” for Slice 1) |
|
||||
| **`intrusion`** | `tech` | Prototype Tech anchor |
|
||||
|
||||
**Rules:** Do **not** rename these `id` values to “fix” wording—change `displayName` only, or add a new `id` and migrate content. CI ([`scripts/validate_content.py`](../../../scripts/validate_content.py)) enforces **exactly** these three ids across all skill JSON files and **category coverage** (gather + tech + at least one of process/make). Relaxing or changing that gate belongs in a new Linear issue once the catalog intentionally grows.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Load and validate `SkillDef` from data; expose `SkillCategory` and `UnlockRequirement` for gating.
|
||||
|
|
@ -71,7 +93,7 @@ Add new enum values only with a **schema version** / migration note in [CT.M1](C
|
|||
|
||||
- Every loaded `SkillDef` validates against [`skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) in CI ([CT.M1](CT_M1_ContentValidationPipeline.md#ci-prototype) — [`scripts/validate_content.py`](../../../scripts/validate_content.py) in **PR gate**) or at server boot.
|
||||
- **Duplicate `id`** in a catalog fails validation.
|
||||
- **Roster freeze for prototype:** when Slice 1 locks three skills, their **`id`** values are treated as **stable**—downstream content (recipes, quests) may reference them; changing an `id` requires a **migration** or new row.
|
||||
- **Roster freeze for prototype:** Slice 1 locks **`salvage`**, **`refine`**, and **`intrusion`**; their **`id`** values are **stable**—downstream content (recipes, quests) may reference them; changing an `id` requires a **migration** or new row.
|
||||
- Document for designers: each new skill row **must** declare **`allowedXpSourceKinds`** so engineers know which systems may grant XP (gather/craft hooks vs E7.M2 mission rewards only, etc.).
|
||||
|
||||
## Module dependencies
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
|
|||
| E1.M2 | Ready | **Slice 2 prototype slice closed:** follow + `CameraState` ([NEO-15](https://linear.app/neon-sprawl/issue/NEO-15)); zoom bands ([NEO-16](https://linear.app/neon-sprawl/issue/NEO-16)); occlusion ([NEO-17](https://linear.app/neon-sprawl/issue/NEO-17)); occluder pick-through ([NEO-20](https://linear.app/neon-sprawl/issue/NEO-20)); contracts + hardening ([NEO-18](https://linear.app/neon-sprawl/issue/NEO-18)). Client-local; no server use of camera pose. | [NEO-15](../../plans/NEO-15-implementation-plan.md), [NEO-16](../../plans/NEO-16-implementation-plan.md), [NEO-17](../../plans/NEO-17-implementation-plan.md), [NEO-18](../../plans/NEO-18-implementation-plan.md), [NEO-20](../../plans/NEO-20-implementation-plan.md); `client/scripts/isometric_follow_camera.gd`, `camera_state.gd`, `zoom_band_config.gd`, `occlusion_policy.gd`, `ground_pick.gd`; [E1_M2_IsometricCameraController.md](E1_M2_IsometricCameraController.md) |
|
||||
| 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 | Planned | **Linear backlog (Todo):** [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36) under [Epic 2 project](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6); label **`E2.M1`**. `content/schemas/skill-def.schema.json`, `content/skills/prototype_skills.json`, and PR-gate `validate_content.py` already exist; **server registry, fail-fast boot load, and read HTTP + Bruno** are not implemented yet. | [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note** |
|
||||
| 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). **Follow-on:** registry service ([NEO-35](https://linear.app/neon-sprawl/issue/NEO-35)), read HTTP + Bruno ([NEO-36](https://linear.app/neon-sprawl/issue/NEO-36)). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-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/` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
|
|||
|
||||
| Module ID | Module Name | Depends On | Contract Needed | Phase Required | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| E2.M1 | SkillDefinitionRegistry | None | SkillDef, SkillCategory, UnlockRequirement, allowedXpSourceKinds | Prototype | Planned |
|
||||
| E2.M1 | SkillDefinitionRegistry | None | SkillDef, SkillCategory, UnlockRequirement, allowedXpSourceKinds | Prototype | In Progress |
|
||||
| E2.M2 | XpAwardAndLevelEngine | E2.M1 | XpGrantEvent, LevelCurve, LevelUpEvent | Prototype | Planned |
|
||||
| E2.M3 | MasteryAndPerkUnlocks | E2.M2 | MasteryTrack, PerkUnlockEvent, PerkState | Pre-production | Planned |
|
||||
| E2.M4 | ProgressionPacingControls | E2.M2, E9.M2 | XpModifierProfile, CatchUpRule, PacingPolicy | Pre-production | Planned |
|
||||
|
||||
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). Register stays **Planned** until implementation merges; update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when code lands.
|
||||
**E2.M1 note:** Epic 2 Slice 1 backlog in Linear ([Epic 2 — Classless Skill and Progression Framework](https://linear.app/neon-sprawl/project/epic-2-classless-skill-and-progression-framework-5200f84bf8b6)): [NEO-33](https://linear.app/neon-sprawl/issue/NEO-33) → [NEO-34](https://linear.app/neon-sprawl/issue/NEO-34) → [NEO-35](https://linear.app/neon-sprawl/issue/NEO-35) → [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36); label **`E2.M1`**. See [E2_M1_SkillDefinitionRegistry.md](E2_M1_SkillDefinitionRegistry.md). **NEO-33** (content + CI + docs) moves the register row to **In Progress**; **NEO-34+** cover server registry and HTTP. Update [documentation and implementation alignment](documentation_and_implementation_alignment.md) when slices land.
|
||||
|
||||
### Epic 3 — Crafting Economy
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ Layout mirrors **[gigs.md](gigs.md) gig roster**: one **table per category**, **
|
|||
|
||||
- **Production chain shape:** **Agreed**—(1) **Overlapping** paths: shared **inputs**, divergent **outputs** across **Process** / **Make**; (2) **Multi-stage** depth: **Gather → Process → Process → Make** (or longer), with **any** **Process** skill able to participate. Recipe UX and balance **open** (roster intro + **Process** subsection in [Skill roster](#skill-roster-draft-ideas-by-category)).
|
||||
- **Agreed Process boundaries:** **Salvage** vs **Quarry**; **Refine** vs **Synth-chem prep** (physical materials vs bio/chem); **Refine** vs **Fab tech** (base material grades vs physical electromechanical fab); **Fab tech** vs **Data scrub** (physical components vs **data/firmware** on salvage). Further merge/split ideas — **open** as the roster evolves.
|
||||
- **Next pass:** pick **prototype trio** (one Gather, one Process/Make, one Tech) for Epic 2/3 slices—written anchors for gather/craft legs: [gathering.md](gathering.md), [crafting.md](crafting.md); lock **`SkillDef` ids** in data with [E2.M1](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md) when Slice 1 freezes.
|
||||
- **Prototype trio (Slice 1 — frozen):** **`salvage`** (Gather), **`refine`** (Process), **`intrusion`** (Tech) — canonical in [`content/skills/prototype_skills.json`](../../content/skills/prototype_skills.json); roster policy and XP channels in [E2.M1](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md). Written anchors for gather/craft legs: [gathering.md](gathering.md), [crafting.md](crafting.md).
|
||||
|
||||
### XP, levels, curves
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
# NEO-34 — Manual QA checklist
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Key | NEO-34 |
|
||||
| Title | E2.M1: Server loads SkillDef catalog at startup (fail-fast) |
|
||||
| Linear | https://linear.app/neon-sprawl/issue/NEO-34/e2m1-server-loads-skilldef-catalog-at-startup-fail-fast |
|
||||
| Plan | `docs/plans/NEO-34-implementation-plan.md` |
|
||||
| Branch | `NEO-34-e2m1-skill-catalog-startup` |
|
||||
|
||||
## 1) Happy path — discovery + health
|
||||
|
||||
- [ ] Repo has `content/skills` and `content/schemas/skill-def.schema.json`.
|
||||
- [ ] From `server/NeonSprawl.Server`, run `dotnet run` with **no** `Content__*` overrides. Startup logs an **Information** line from category `NeonSprawl.Server.Game.Skills.SkillCatalog` with the **absolute** skills directory path and **3** skills (prototype catalog).
|
||||
- [ ] `GET /health` (or Bruno `core/Health`) returns **200** and JSON includes `service: "NeonSprawl.Server"`.
|
||||
|
||||
## 2) Fail-fast — invalid catalog
|
||||
|
||||
- [ ] Temporarily introduce a catalog error (e.g. duplicate an `id` in `content/skills/prototype_skills.json`). `dotnet run` **exits** before listening; stderr includes **Skill catalog validation failed** and a path/rule hint. **Revert** the edit after the check.
|
||||
|
||||
## 3) Optional — explicit config
|
||||
|
||||
- [ ] Set `Content__SkillsDirectory` to a temp directory with invalid or empty catalogs and confirm the same fail-fast behavior (see [server README — Skill catalog](../../server/README.md#skill-catalog-contentskills-neo-34)).
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# NEO-33 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-33 |
|
||||
| **Title** | E2.M1: Lock prototype SkillDef trio + CI catalog contract |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-33/e2m1-lock-prototype-skilldef-trio-ci-catalog-contract |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Answer |
|
||||
|--------|------------|--------|
|
||||
| Prototype `SkillDef.id` freeze | Lock **salvage**, **refine**, **intrusion** (gather + process + tech)? | **Yes** — freeze these three ids as-is. |
|
||||
| Designer note placement | Where should the short note on `allowedXpSourceKinds` + grant call sites live? | **Both** — brief pointer in `content/README.md`, fuller text in `docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md`. |
|
||||
| `allowedXpSourceKinds` meaning | (User asked in chat.) | Explained in session: per-skill allowlist for XP grant channels (`activity`, `mission_reward`, `trainer`, `book_or_item`); E2.M2 rejects grants outside the set; combat encounter XP stays on gigs, not skills. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** Treat the repo as the source of truth for the **Slice 1 prototype skill catalog**: exactly **three** non-combat `SkillDef` rows covering **gather**, **process** (or make), and **tech**, with stable **`id`** values, validated on every PR against `content/schemas/skill-def.schema.json`, and documented for designers and downstream issues (NEO-34+).
|
||||
|
||||
**In scope (from Linear):**
|
||||
|
||||
- Align `content/skills/prototype_skills.json` (and schema only if contract gaps appear) with [`docs/game-design/skills.md`](../../game-design/skills.md) prototype trio policy (one Gather, one Process/Make, one Tech).
|
||||
- Confirm [`.github/workflows/pr-gate.yml`](../../.github/workflows/pr-gate.yml) runs [`scripts/validate_content.py`](../../scripts/validate_content.py); close small gaps if any (e.g. clearer validation errors, doc pointers).
|
||||
- Short designer note (**README + E2.M1** per kickoff): each row **must** declare non-empty `allowedXpSourceKinds` and what each kind implies for grant call sites.
|
||||
- Call out the **frozen** `id` list in-repo for downstream references (E2.M2, E3, quests).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Server runtime catalog load (NEO-34+).
|
||||
- XP grant implementation (E2.M2 / NEO follow-ups).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] PR / CI fails on invalid catalog rows, schema mismatch, or **duplicate** skill `id` across `content/skills/*.json`.
|
||||
- [x] Exactly **three** prototype skills with category coverage **gather** + **process** (or make) + **tech**; **`id`** values **salvage**, **refine**, **intrusion** recorded as **stable / frozen** in decomposition or authoring docs.
|
||||
- [x] CT.M1 and E2.M1 doc pointers remain accurate relative to what ships in this story.
|
||||
- [x] Designer-facing note on `allowedXpSourceKinds` appears in **`content/README.md`** (brief) and **`E2_M1_SkillDefinitionRegistry.md`** (detail).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Catalog:** Keep `content/skills/prototype_skills.json` as the single skills JSON under `content/skills/` for the prototype trio (already matches gather / process / tech). Adjust rows or schema only if validation or design review finds drift from `skills.md` policy.
|
||||
2. **CI:** Rely on existing `validate_content.py` + PR gate; optionally tighten messages or add explicit checks (e.g. total distinct `id` count equals three for Slice 1) if product wants fail-fast on accidental extra files—only if agreed during implementation without scope creep.
|
||||
3. **Docs:** Update **E2.M1** with a **Prototype Slice 1 freeze** subsection: frozen ids, category coverage, and a **grant channels** subsection expanding `allowedXpSourceKinds` for designers (link to schema enum). Update **content/README.md** with a short pointer to that section and a one-line “do not rename ids” reminder.
|
||||
4. **Cross-doc:** Update `skills.md` “Next pass” / prototype language so it points at the **locked** ids (no longer an open pick). Refresh [`documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) E2.M1 row if it still implies NEO-33 work is only backlog.
|
||||
5. **No application server or C# changes** in this story unless an unexpected coupling appears (unlikely).
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| **None** | All contracts and catalogs already exist; this story is documentation alignment, optional validation tightening, and explicit freeze callouts—not new modules. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|------------|
|
||||
| `docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md` | Add frozen trio + designer-oriented `allowedXpSourceKinds` / grant call-site guidance (canonical). |
|
||||
| `content/README.md` | Brief pointer to E2.M1 detail and freeze reminder for content authors. |
|
||||
| `docs/game-design/skills.md` | Point “prototype trio” at locked ids so design doc matches data. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Update E2.M1 / NEO-33 row to match shipped state after implementation. |
|
||||
| `scripts/validate_content.py` | Shipped **NEO-33** Slice 1 gate: exact frozen ids + category coverage after schema + duplicate-id checks. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| **Automated** | Existing **PR gate** job runs `python scripts/validate_content.py` — primary regression signal for schema + duplicate `id`. No dedicated pytest module exists in-repo today; introducing one is **optional** and not required for NEO-33 acceptance if CI + local script remain the contract. |
|
||||
| **Manual** | Run `pip install -r scripts/requirements-content.txt && python scripts/validate_content.py` locally after doc/catalog edits; skim PR gate workflow in GitHub on the story PR. |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Catalog growth:** `validate_content.py` now requires **exactly** the three frozen ids; expanding the roster requires a follow-up issue to relax or replace this gate.
|
||||
- **None** otherwise at kickoff.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|------------|
|
||||
| Frozen ids **salvage**, **refine**, **intrusion** | User confirmation during kickoff. |
|
||||
| Designer note in **README + E2.M1** | User picked option **3**. |
|
||||
|
||||
## Implementation notes (shipped)
|
||||
|
||||
- **`scripts/validate_content.py`:** after schema validation, enforces `PROTOTYPE_SLICE1_SKILL_IDS` and category coverage (gather, tech, process or make).
|
||||
- **Docs:** E2.M1 designer note + freeze table; `content/README` pointer; `skills.md` frozen trio bullet; CT.M1 CI paragraph; alignment + dependency register **E2.M1** → **In Progress**.
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
# NEO-34 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-34 |
|
||||
| **Title** | E2.M1: Server loads SkillDef catalog at startup (fail-fast) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-34/e2m1-server-loads-skilldef-catalog-at-startup-fail-fast |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Answer |
|
||||
|--------|------------|--------|
|
||||
| NEO-33 dependency / sequencing | Plan relative to NEO-33 merge? | **NEO-33 merged** — assume current `content/skills/*.json` + [`scripts/validate_content.py`](../../scripts/validate_content.py) behavior is authoritative. |
|
||||
| Runtime validation strategy (mirror Python vs subprocess vs defer to implementer) | C#, subprocess, or agent picks? | **Agent default:** in-process **C#** validation mirroring `validate_content.py` (JSON Schema Draft 2020-12 + same Slice-1 id/category gate). **Rationale:** no Python runtime on minimal server/CI images; fail-fast with clear paths in exceptions/logs; drift risk mitigated by parallel test fixtures and shared constants documented next to the Python script. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** On host startup, load all `*.json` under the configured skills catalog directory (same layout as CI: top-level `skills` array per file), validate every row against `content/schemas/skill-def.schema.json` rules, enforce **global duplicate `id`**, apply the **NEO-33 prototype Slice 1 gate** (exact ids + category coverage), build an in-memory catalog, **log** skill count and resolved paths at **Information** (or Debug for noisy detail), and register the result in DI for **NEO-35** (lookup service). **Invalid/missing data prevents the host from listening.**
|
||||
|
||||
**In scope (from Linear):**
|
||||
|
||||
- Configurable **content / skills root** (absolute path or path relative to the server’s content root / current working directory — documented in `server/README.md`).
|
||||
- Fail-fast semantics aligned with [`scripts/validate_content.py`](../../scripts/validate_content.py) and [E2.M1 — SkillDefinitionRegistry](../decomposition/modules/E2_M1_SkillDefinitionRegistry.md).
|
||||
- Singleton (or equivalent) registration holding loaded definitions for follow-on stories.
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- Public HTTP API for skills (**NEO-36**).
|
||||
- XP / level engine (**E2.M2**).
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Integration or host-level test: **valid** fixture directory → `WebApplicationFactory` (or equivalent) starts and catalog is resolvable from DI.
|
||||
- [x] Integration or host-level test: **invalid** fixture (malformed JSON, schema violation, duplicate `id`, or Slice-1 gate failure) → host startup **fails** with an **actionable** error (message includes file path and rule hint where practical).
|
||||
- [x] At success: log includes **skill count** and **catalog directory** (Information or Debug per choice above).
|
||||
- [x] No silent acceptance of bad catalog at runtime (matches E2.M1 acceptance).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **Configuration:** Add a `Content` (or `SkillCatalog`) section, e.g. `SkillsDirectory` — optional string. If unset, resolve default by walking ancestors of `AppContext.BaseDirectory` until a directory `content/skills` exists (supports `dotnet run` from `server/NeonSprawl.Server` without extra env). Document override for Docker/CI via env/config.
|
||||
2. **Validation library:** Add **`JsonSchema.Net`** (or equivalent with Draft 2020-12) to `NeonSprawl.Server.csproj`; load `skill-def.schema.json` once. Replicate `validate_content.py` loop: enumerate `*.json`, parse JSON, validate each object in `skills`, duplicate detection only for schema-clean rows, then run the same **frozen id set** and **category coverage** checks as Python’s `_prototype_slice1_gate` (share the three ids as a single named constant in C#; cross-reference the Python constant in a short comment).
|
||||
3. **Model:** Minimal POCOs for load-time data (`Id`, `Category`, `DisplayName`, `AllowedXpSourceKinds`, etc.) — enough for registry identity and logging; NEO-35 can refine public surface.
|
||||
4. **Registration:** `AddSkillDefinitionCatalog(builder.Configuration)` registers options + `SkillDefinitionCatalog` singleton; **eager** creation after `var app = builder.Build()` via `app.Services.GetRequiredService<SkillDefinitionCatalog>()` (or a tiny `IHostedService` that only validates in `StartAsync` if we need ordering with other startup) so `app.Run()` never starts with a bad catalog.
|
||||
5. **Logging:** Inject `ILogger` into loader or log from `Program` after successful resolve — include absolute resolved skills directory path and distinct skill count.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` (or `SkillCatalogOptions.cs`) | Options type bound from configuration (`SkillsDirectory`, optional future `SchemaPath`). |
|
||||
| `server/NeonSprawl.Server/Game/Skills/SkillCatalogServiceCollectionExtensions.cs` | `AddSkillDefinitionCatalog` + options validation. |
|
||||
| `server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalog.cs` | Immutable in-memory result of load (e.g. dictionary by id + read-only list). |
|
||||
| `server/NeonSprawl.Server/Game/Skills/SkillDefinitionCatalogLoader.cs` | File I/O + schema validation + Slice-1 gate; throws typed/`InvalidOperationException` with clear messages on failure. |
|
||||
| `server/NeonSprawl.Server/Game/Skills/SkillDefRow.cs` (or similar) | DTO for one catalog row after validation. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/NeonSprawl.Server.csproj` | Add JSON Schema NuGet package; optionally `Content` link to copy or embed schema path strategy (prefer reading schema from resolved repo `content/schemas` next to skills dir, or single `ContentRoot` option — align with loader). |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register catalog; **eager-resolve** after build so startup fails before listen; no new public routes. |
|
||||
| `server/NeonSprawl.Server/appsettings.json` | Optional `Content` section: `SkillsDirectory` / `SkillDefSchemaPath` as empty strings (auto-discovery); override via env or user secrets per [server README](../../server/README.md#skill-catalog-contentskills-neo-34). |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Set default `SkillsDirectory` / content overrides so existing tests use a **valid temp or repo-relative** catalog path (tests must not depend on undeclared cwd). |
|
||||
| `server/README.md` | Document `Content:SkillsDirectory`, default discovery, and Docker/CI override. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Test file | What it covers |
|
||||
|-----------|------------------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionCatalogLoaderTests.cs` | **Unit:** loader against temp directories — valid minimal catalog passes; cases for bad `skills` type, schema error, duplicate id across files, Slice-1 wrong ids / missing category coverage; assert exception messages mention file path. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI catalog resolution; `WebApplicationFactory` negative case for invalid skills directory (merged here instead of a separate `SkillCatalogStartupIntegrationTests.cs`, per kickoff plan). |
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Schema path vs skills path:** If only `SkillsDirectory` is configured, schema resolution should be deterministic (e.g. `content/schemas/skill-def.schema.json` under the inferred repo root parent of `skills`, or a second optional setting). Document the rule in code + README to avoid Docker surprises.
|
||||
- **Test cwd:** CI runs `dotnet test` from `server/` or repo root — default discovery and test overrides must be verified in both if applicable.
|
||||
|
||||
None blocking beyond the above (handled in implementation).
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Code review — NEO-33 (prototype SkillDef trio + CI catalog contract)
|
||||
|
||||
**Date:** 2026-05-02
|
||||
|
||||
**Scope:** Branch `NEO-33-e2m1-lock-prototype-skilldef-trio-ci-catalog-contract` vs `origin/main` (commits `66150ea`, `583201c`: implementation plan, `validate_content.py` Slice 1 gate, E2.M1 / CT.M1 / alignment / register / `content/README` / `skills.md`).
|
||||
|
||||
**Base:** `origin/main` at merge-base `807812148ffa445d3628e6fed1337d53790c3c77`.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
The branch implements NEO-33 as documented: a post-schema **prototype Slice 1** gate in `scripts/validate_content.py` enforces the frozen id set (`salvage`, `refine`, `intrusion`) and category coverage (gather, tech, process or make), with decomposition and authoring docs updated so designers see `allowedXpSourceKinds` semantics and the freeze table. `prototype_skills.json` on `main` already matched the trio; no catalog diff is expected. Risk is low (CI + Python script only); one small validator behavior affects error messaging in an edge case, not happy-path correctness.
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| `docs/plans/NEO-33-implementation-plan.md` | **Matches** — plan, shipped notes, acceptance checklist, and “Files to modify” align with the diff (catalog unchanged on branch because it was already correct; gate + docs carry the story). |
|
||||
| `docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md` | **Matches** — Status **In Progress**, designer note, freeze table, CI pointer, `allowedXpSourceKinds` table + narrative; schema link remains in **Data contract** above the new subsection. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **Matches** — CI paragraph describes duplicate-id check and NEO-33 trio + category enforcement. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E2.M1 **In Progress** and E2.M1 note describe NEO-33 → NEO-34+ flow. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E2.M1 row documents NEO-33 landed state and follow-on issues. |
|
||||
| `docs/game-design/skills.md` | **Matches** — Prototype trio bullet points at frozen ids and E2.M1 / catalog anchors. |
|
||||
| `content/README.md` | **Matches** — Brief Slice 1 pointer and anchor to E2.M1 designer section. |
|
||||
| `docs/decomposition/modules/contracts.md` | **N/A** — no new contract kind beyond existing JSON Schema + CI story; consistent with CT.M1 wording. |
|
||||
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — no runtime authority change. |
|
||||
|
||||
**Register / tracking:** Branch already updates E2.M1 to **In Progress** and the alignment row; no further register change required for merge.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**`validate_content.py` — reserve `seen_ids` only for schema-clean rows** — Today, a string `id` is inserted into `seen_ids` even when `row_schema_errors > 0`, while `id_to_category` is gated on `row_schema_errors == 0`. If an author fixes a broken row and reuses an `id` that appeared only on an invalid earlier row, the validator can report **duplicate skill id** instead of focusing on the remaining schema errors. Consider only updating `seen_ids` when `row_schema_errors == 0` (mirror the `id_to_category` guard) so duplicate detection applies to validated rows only.~~ **Done.**
|
||||
|
||||
2. ~~**E2.M1 acceptance criteria (optional doc polish)** — The bullet that says “when Slice 1 locks three skills” could name **`salvage` / `refine` / `intrusion`** explicitly now that NEO-33 shipped, so the acceptance section matches the freeze subsection without reading both.~~ **Done.**
|
||||
|
||||
## Nits
|
||||
|
||||
- **Nit:** Ensure the PR description mentions that **`content/skills/prototype_skills.json`** was already compliant on `main` so reviewers do not hunt for a missing catalog diff.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pip install -r scripts/requirements-content.txt && python3 scripts/validate_content.py` (expect `content OK` and exit code 0).
|
||||
- Confirm **PR gate** on the PR runs the same script (`.github/workflows/pr-gate.yml` already invokes `python scripts/validate_content.py`).
|
||||
- Optional: add a second skills JSON under `content/skills/` in a throwaway branch to confirm duplicate-id and “exactly three ids” failures read clearly in CI logs.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Code review — NEO-34 (skill catalog startup)
|
||||
|
||||
**Date:** 2026-05-02
|
||||
|
||||
**Scope:** Branch `NEO-34-e2m1-skill-catalog-startup` vs `origin/main` (commits through `1a9088c`); working tree review.
|
||||
|
||||
**Base:** `origin/main`
|
||||
|
||||
## Verdict
|
||||
|
||||
Approve with nits
|
||||
|
||||
## Summary
|
||||
|
||||
The branch adds fail-fast loading of `content/skills/*.json` at server startup: JSON Schema validation (JsonSchema.Net), duplicate `id` detection, and a C# mirror of the NEO-33 prototype Slice 1 gate aligned with `scripts/validate_content.py`. `SkillDefinitionCatalog` is registered as a singleton and eagerly resolved in `Program.cs` so a bad catalog prevents the host from reaching a listening state. Unit tests cover the loader; an integration-style test uses `InMemoryWebApplicationFactory` and a `WebApplicationFactory` negative case for an empty skills directory. `server/README.md` documents `Content:SkillsDirectory` and schema resolution. All 105 tests in `NeonSprawl.sln` passed locally (`dotnet test NeonSprawl.sln`).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result | Notes |
|
||||
|----------|--------|--------|
|
||||
| `docs/plans/NEO-34-implementation-plan.md` | Matches | Acceptance criteria and technical approach match implementation; `appsettings.json` includes optional `Content` keys; **Tests** table documents merged host coverage in `SkillDefinitionCatalogLoaderTests.cs`. Done. |
|
||||
| `docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md` | Matches | Load-time validation against `skill-def.schema.json`, duplicate `id`, and Slice 1 roster rules align with module intent. |
|
||||
| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | Matches | Server-side validation complements CI `validate_content.py`; same schema and gate semantics. |
|
||||
| `docs/decomposition/modules/module_dependency_register.md` | Matches | E2.M1 remains an appropriate home; no contract contradiction. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | Matches | E2.M1 tracking row updated for **NEO-34 landed** + pointers. Done. |
|
||||
| `docs/manual-qa/NEO-34.md` | Matches | Standard header table and numbered sections aligned with NEO-31 style. Done. |
|
||||
|
||||
## Blocking issues
|
||||
|
||||
(none)
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~After merge (or in this PR): update **`documentation_and_implementation_alignment.md`** E2.M1 snapshot to include NEO-34 server load and pointers to `Game/Skills/` + README, so the tracking table matches reality.~~ Done.
|
||||
2. ~~Either add a short commented **`Content`** subsection to **`server/NeonSprawl.Server/appsettings.json`** as the plan described, or remove/adjust that row in **`NEO-34-implementation-plan.md`** so the plan matches what shipped.~~ Done (placeholder `Content` keys in `appsettings.json` + plan row aligned).
|
||||
3. ~~Expand **`docs/manual-qa/NEO-34.md`** with the standard header table (Key, Title, Linear, Plan, Branch) and numbered sections for consistency with other story QA files.~~ Done.
|
||||
|
||||
## Nits
|
||||
|
||||
- **`bruno/neon-sprawl-server/core/Health.bru`**: Added Bruno `tests` blocks for status and `service` identity. Useful, but not called out in the NEO-34 plan; consider mentioning in the PR description or splitting if you want a strictly single-concern PR. *(Unchanged — optional PR hygiene.)*
|
||||
- ~~**`SkillDefinitionCatalogLoaderTests.cs`**: `Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds` puts `GetRequiredService<SkillDefinitionCatalog>()` under **Act** alongside `GetAsync("/health")`; strict AAA would treat DI resolution as **Assert** only (style only).~~ Done.
|
||||
|
||||
## Verification
|
||||
|
||||
- `dotnet test NeonSprawl.sln` (from repo root) — expect all tests green.
|
||||
- Manual: follow `docs/manual-qa/NEO-34.md` after any edits to that file.
|
||||
|
|
@ -17,6 +17,31 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||
SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
|
||||
SKILLS_DIR = REPO_ROOT / "content/skills"
|
||||
|
||||
# Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
|
||||
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"})
|
||||
|
||||
|
||||
def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None:
|
||||
"""Return a human-readable error if Slice 1 contract fails, else None."""
|
||||
ids = frozenset(seen_ids.keys())
|
||||
if ids != PROTOTYPE_SLICE1_SKILL_IDS:
|
||||
return (
|
||||
"error: prototype Slice 1 expects exactly skill ids "
|
||||
f"{sorted(PROTOTYPE_SLICE1_SKILL_IDS)!r}, got {sorted(ids)!r}"
|
||||
)
|
||||
cats = set(id_to_category.values())
|
||||
if "gather" not in cats or "tech" not in cats:
|
||||
return (
|
||||
"error: prototype Slice 1 requires at least one gather and one tech skill; "
|
||||
f"categories seen: {sorted(cats)!r}"
|
||||
)
|
||||
if "process" not in cats and "make" not in cats:
|
||||
return (
|
||||
"error: prototype Slice 1 requires at least one process or make skill; "
|
||||
f"categories seen: {sorted(cats)!r}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SKILL_SCHEMA.is_file():
|
||||
|
|
@ -36,6 +61,7 @@ def main() -> int:
|
|||
return 1
|
||||
|
||||
seen_ids: dict[str, str] = {}
|
||||
id_to_category: dict[str, str] = {}
|
||||
errors = 0
|
||||
|
||||
for path in json_files:
|
||||
|
|
@ -50,15 +76,19 @@ def main() -> int:
|
|||
print(f"error: {path.relative_to(REPO_ROOT)}: skills[{i}] must be an object", file=sys.stderr)
|
||||
errors += 1
|
||||
continue
|
||||
row_schema_errors = 0
|
||||
for err in sorted(validator.iter_errors(row), key=lambda e: e.path):
|
||||
loc = ".".join(str(p) for p in err.path) or "(root)"
|
||||
print(
|
||||
f"error: {path.relative_to(REPO_ROOT)} skills[{i}] {loc}: {err.message}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
row_schema_errors += 1
|
||||
errors += 1
|
||||
sid = row.get("id")
|
||||
if isinstance(sid, str):
|
||||
# Reserve seen_ids (and duplicate detection) only for schema-clean rows so
|
||||
# fixing a broken row does not surface a spurious duplicate vs an earlier invalid row.
|
||||
if isinstance(sid, str) and row_schema_errors == 0:
|
||||
prev = seen_ids.get(sid)
|
||||
if prev:
|
||||
print(
|
||||
|
|
@ -68,11 +98,19 @@ def main() -> int:
|
|||
errors += 1
|
||||
else:
|
||||
seen_ids[sid] = str(path.relative_to(REPO_ROOT))
|
||||
cat = row.get("category")
|
||||
if isinstance(cat, str):
|
||||
id_to_category[sid] = cat
|
||||
|
||||
if errors:
|
||||
print(f"content validation failed with {errors} error(s)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
slice1_err = _prototype_slice1_gate(seen_ids, id_to_category)
|
||||
if slice1_err:
|
||||
print(slice1_err, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"content OK: {len(json_files)} skill catalog file(s), {len(seen_ids)} unique skill id(s)")
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.PositionState;
|
||||
|
||||
|
|
@ -17,6 +18,11 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
|
|||
"Set ConnectionStrings__NeonSprawl for Postgres integration tests (see server/README.md).");
|
||||
}
|
||||
|
||||
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
using NeonSprawl.Server.Game.Skills;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Skills;
|
||||
|
||||
internal static class SkillCatalogTestPaths
|
||||
{
|
||||
internal static string DiscoverRepoSkillsDirectory() =>
|
||||
SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
|
||||
internal static string DiscoverRepoSkillDefSchemaPath() =>
|
||||
SkillCatalogPathResolution.ResolveSkillDefSchemaPath(
|
||||
DiscoverRepoSkillsDirectory(),
|
||||
configuredSchemaPath: null,
|
||||
contentRootPath: string.Empty);
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NeonSprawl.Server.Tests;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using Xunit;
|
||||
|
||||
namespace NeonSprawl.Server.Tests.Game.Skills;
|
||||
|
||||
public class SkillDefinitionCatalogLoaderTests
|
||||
{
|
||||
private const string ValidPrototypeCatalogJson =
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skills": [
|
||||
{
|
||||
"id": "salvage",
|
||||
"category": "gather",
|
||||
"displayName": "Salvage",
|
||||
"allowedXpSourceKinds": ["activity", "mission_reward"]
|
||||
},
|
||||
{
|
||||
"id": "refine",
|
||||
"category": "process",
|
||||
"displayName": "Refine",
|
||||
"allowedXpSourceKinds": ["activity", "mission_reward", "trainer"]
|
||||
},
|
||||
{
|
||||
"id": "intrusion",
|
||||
"category": "tech",
|
||||
"displayName": "Intrusion",
|
||||
"allowedXpSourceKinds": ["activity", "mission_reward", "book_or_item"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private static (string Root, string SkillsDir, string SchemaPath) CreateTempContentLayout()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory("neon-sprawl-skillcat-");
|
||||
var skillsDir = Path.Combine(root.FullName, "content", "skills");
|
||||
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
|
||||
Directory.CreateDirectory(skillsDir);
|
||||
Directory.CreateDirectory(schemaDir);
|
||||
var schemaPath = Path.Combine(schemaDir, "skill-def.schema.json");
|
||||
File.Copy(SkillCatalogTestPaths.DiscoverRepoSkillDefSchemaPath(), schemaPath, overwrite: true);
|
||||
return (root.FullName, skillsDir, schemaPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
|
||||
{
|
||||
// Arrange
|
||||
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(skillsDir, "prototype.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
|
||||
// Act
|
||||
var catalog = SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance);
|
||||
// Assert
|
||||
Assert.Equal(3, catalog.DistinctSkillCount);
|
||||
Assert.Equal(1, catalog.CatalogJsonFileCount);
|
||||
Assert.True(catalog.ById.ContainsKey("salvage"));
|
||||
Assert.Equal("gather", catalog.ById["salvage"].Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSkillsIsNotArray()
|
||||
{
|
||||
// Arrange
|
||||
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
|
||||
File.WriteAllText(Path.Combine(skillsDir, "bad.json"), """{"skills": "nope"}""", Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("expected top-level 'skills' array", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenRowViolatesSchema()
|
||||
{
|
||||
// Arrange
|
||||
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
|
||||
var bad = """
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"id": "salvage",
|
||||
"category": "gather",
|
||||
"displayName": "",
|
||||
"allowedXpSourceKinds": ["activity"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(skillsDir, "bad.json"), bad, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("bad.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("Skill catalog validation failed", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
|
||||
var singleSalvage = """
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"id": "salvage",
|
||||
"category": "gather",
|
||||
"displayName": "Salvage",
|
||||
"allowedXpSourceKinds": ["activity"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(skillsDir, "a.json"), singleSalvage, Encoding.UTF8);
|
||||
File.WriteAllText(Path.Combine(skillsDir, "b.json"), singleSalvage, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("duplicate skill id", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("salvage", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("a.json", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("b.json", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenSlice1IdsIncomplete()
|
||||
{
|
||||
// Arrange
|
||||
var (_, skillsDir, schemaPath) = CreateTempContentLayout();
|
||||
var twoOnly = """
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"id": "salvage",
|
||||
"category": "gather",
|
||||
"displayName": "Salvage",
|
||||
"allowedXpSourceKinds": ["activity"]
|
||||
},
|
||||
{
|
||||
"id": "refine",
|
||||
"category": "process",
|
||||
"displayName": "Refine",
|
||||
"allowedXpSourceKinds": ["activity"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(skillsDir, "partial.json"), twoOnly, Encoding.UTF8);
|
||||
// Act
|
||||
var ex = Record.Exception(() => SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("prototype Slice 1 expects exactly skill ids", ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange — InMemoryWebApplicationFactory strips Postgres and pins Content:SkillsDirectory to the repo catalog.
|
||||
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<SkillDefinitionCatalog>();
|
||||
Assert.Equal(3, catalog.DistinctSkillCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-skills-" + Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(badDir);
|
||||
// Act
|
||||
var ex = Record.Exception(() =>
|
||||
{
|
||||
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
||||
b.UseSetting("Content:SkillsDirectory", badDir));
|
||||
factory.CreateClient();
|
||||
});
|
||||
// Assert
|
||||
Assert.NotNull(ex);
|
||||
Assert.Contains("Skill catalog validation failed", ex.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.Skills;
|
||||
using Npgsql;
|
||||
|
||||
namespace NeonSprawl.Server.Tests;
|
||||
|
|
@ -18,6 +19,11 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
|||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
var skillsDir = SkillCatalogPathResolution.TryDiscoverSkillsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not discover repo content/skills from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
for (var i = services.Count - 1; i >= 0; i--)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Configuration for loading authoring content from disk (NEO-34).</summary>
|
||||
public sealed class ContentPathsOptions
|
||||
{
|
||||
public const string SectionName = "Content";
|
||||
|
||||
/// <summary>
|
||||
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/skills</c> directory.
|
||||
/// </summary>
|
||||
public string? SkillsDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional override for <c>skill-def.schema.json</c>.
|
||||
/// When unset, resolved as <c>{parent of skills directory}/schemas/skill-def.schema.json</c> (same layout as the repo).
|
||||
/// </summary>
|
||||
public string? SkillDefSchemaPath { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype Slice 1 roster gate (NEO-33), mirrored from <c>scripts/validate_content.py</c>
|
||||
/// <c>PROTOTYPE_SLICE1_SKILL_IDS</c> / <c>_prototype_slice1_gate</c>.
|
||||
/// </summary>
|
||||
public static class PrototypeSlice1SkillCatalogRules
|
||||
{
|
||||
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_SKILL_IDS</c>.</summary>
|
||||
public static readonly FrozenSet<string> ExpectedSkillIds = FrozenSet.ToFrozenSet(
|
||||
["salvage", "refine", "intrusion"],
|
||||
StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a human-readable error if the Slice 1 contract fails, otherwise <see langword="null"/>.</summary>
|
||||
public static string? TryGetSlice1GateError(
|
||||
IReadOnlyDictionary<string, string> skillIdToSourceFile,
|
||||
IReadOnlyDictionary<string, string> skillIdToCategory)
|
||||
{
|
||||
var ids = skillIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
|
||||
if (!ids.SetEquals(ExpectedSkillIds))
|
||||
{
|
||||
return
|
||||
"error: prototype Slice 1 expects exactly skill ids " +
|
||||
$"[{string.Join(", ", ExpectedSkillIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
|
||||
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
|
||||
}
|
||||
|
||||
var cats = skillIdToCategory.Values.ToHashSet(StringComparer.Ordinal);
|
||||
if (!cats.Contains("gather") || !cats.Contains("tech"))
|
||||
{
|
||||
return
|
||||
"error: prototype Slice 1 requires at least one gather and one tech skill; " +
|
||||
$"categories seen: [{string.Join(", ", cats.Order(StringComparer.Ordinal).Select(c => "'" + c + "'"))}]";
|
||||
}
|
||||
|
||||
if (!cats.Contains("process") && !cats.Contains("make"))
|
||||
{
|
||||
return
|
||||
"error: prototype Slice 1 requires at least one process or make skill; " +
|
||||
$"categories seen: [{string.Join(", ", cats.Order(StringComparer.Ordinal).Select(c => "'" + c + "'"))}]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Resolves catalog paths for local dev, tests, and container layouts (NEO-34).</summary>
|
||||
public static class SkillCatalogPathResolution
|
||||
{
|
||||
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/skills</c> directory.</summary>
|
||||
public static string? TryDiscoverSkillsDirectory(string startDirectory)
|
||||
{
|
||||
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "content", "skills");
|
||||
if (Directory.Exists(candidate))
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the skills catalog directory.
|
||||
/// Empty <paramref name="configuredSkillsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||
/// </summary>
|
||||
public static string ResolveSkillsDirectory(string? configuredSkillsDirectory, string contentRootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredSkillsDirectory))
|
||||
{
|
||||
var discovered = TryDiscoverSkillsDirectory(AppContext.BaseDirectory);
|
||||
if (discovered is not null)
|
||||
return discovered;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Content:SkillsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/skills'). " +
|
||||
"Set Content:SkillsDirectory in configuration or environment (e.g. Content__SkillsDirectory).");
|
||||
}
|
||||
|
||||
var trimmed = configuredSkillsDirectory.Trim();
|
||||
if (Path.IsPathRooted(trimmed))
|
||||
return Path.GetFullPath(trimmed);
|
||||
|
||||
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
|
||||
}
|
||||
|
||||
/// <summary>Resolves JSON Schema path for a single skill row (Draft 2020-12).</summary>
|
||||
public static string ResolveSkillDefSchemaPath(
|
||||
string skillsDirectory,
|
||||
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(skillsDirectory, "..", "schemas", "skill-def.schema.json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>DI registration for the fail-fast skill catalog (NEO-34).</summary>
|
||||
public static class SkillCatalogServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="SkillDefinitionCatalog"/> as a singleton.</summary>
|
||||
public static IServiceCollection AddSkillDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<ContentPathsOptions>()
|
||||
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||
|
||||
services.AddSingleton<SkillDefinitionCatalog>(sp =>
|
||||
{
|
||||
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("NeonSprawl.Server.Game.Skills.SkillCatalog");
|
||||
|
||||
var skillsDir = SkillCatalogPathResolution.ResolveSkillsDirectory(opts.SkillsDirectory, hostEnv.ContentRootPath);
|
||||
var schemaPath = SkillCatalogPathResolution.ResolveSkillDefSchemaPath(
|
||||
skillsDir,
|
||||
opts.SkillDefSchemaPath,
|
||||
hostEnv.ContentRootPath);
|
||||
|
||||
return SkillDefinitionCatalogLoader.Load(skillsDir, schemaPath, logger);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>One validated <c>SkillDef</c> row from <c>content/skills/*.json</c> (NEO-34).</summary>
|
||||
public sealed record SkillDefRow(
|
||||
string Id,
|
||||
string Category,
|
||||
string DisplayName,
|
||||
IReadOnlyList<string> AllowedXpSourceKinds);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>In-memory skill catalog loaded at startup (NEO-34). NEO-35 may wrap with lookup services.</summary>
|
||||
public sealed class SkillDefinitionCatalog
|
||||
{
|
||||
public SkillDefinitionCatalog(
|
||||
string skillsDirectory,
|
||||
IReadOnlyDictionary<string, SkillDefRow> byId,
|
||||
int catalogJsonFileCount)
|
||||
{
|
||||
SkillsDirectory = skillsDirectory;
|
||||
ById = new ReadOnlyDictionary<string, SkillDefRow>(new Dictionary<string, SkillDefRow>(byId, StringComparer.Ordinal));
|
||||
CatalogJsonFileCount = catalogJsonFileCount;
|
||||
}
|
||||
|
||||
/// <summary>Absolute path to the directory that was enumerated for <c>*.json</c> catalogs.</summary>
|
||||
public string SkillsDirectory { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, SkillDefRow> ById { get; }
|
||||
|
||||
public int DistinctSkillCount => ById.Count;
|
||||
|
||||
/// <summary>Number of <c>*.json</c> files under <see cref="SkillsDirectory"/>.</summary>
|
||||
public int CatalogJsonFileCount { get; }
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Json.Schema;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NeonSprawl.Server.Game.Skills;
|
||||
|
||||
/// <summary>Loads and validates <c>content/skills/*.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-34).</summary>
|
||||
public static class SkillDefinitionCatalogLoader
|
||||
{
|
||||
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||
public static SkillDefinitionCatalog Load(string skillsDirectory, string schemaPath, ILogger logger)
|
||||
{
|
||||
skillsDirectory = Path.GetFullPath(skillsDirectory);
|
||||
schemaPath = Path.GetFullPath(schemaPath);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!File.Exists(schemaPath))
|
||||
errors.Add($"error: missing schema file {schemaPath}");
|
||||
|
||||
if (!Directory.Exists(skillsDirectory))
|
||||
errors.Add($"error: missing directory {skillsDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var jsonFiles = Directory.GetFiles(skillsDirectory, "*.json", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (jsonFiles.Length == 0)
|
||||
errors.Add($"error: no JSON files under {skillsDirectory}");
|
||||
|
||||
if (errors.Count > 0)
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
|
||||
|
||||
var skillIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var skillIdToCategory = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var rows = new Dictionary<string, SkillDefRow>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var path in jsonFiles)
|
||||
{
|
||||
JsonNode? root;
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(path));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"error: {path}: invalid JSON: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var skillsNode = root?["skills"];
|
||||
if (skillsNode is not JsonArray skillsArray)
|
||||
{
|
||||
errors.Add($"error: {path}: expected top-level 'skills' array");
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < skillsArray.Count; i++)
|
||||
{
|
||||
var item = skillsArray[i];
|
||||
if (item is not JsonObject rowObj)
|
||||
{
|
||||
errors.Add($"error: {path}: skills[{i}] must be an object");
|
||||
continue;
|
||||
}
|
||||
|
||||
var eval = schema.Evaluate(rowObj, evalOptions);
|
||||
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
|
||||
if (!eval.IsValid)
|
||||
{
|
||||
if (schemaMsgs.Count == 0)
|
||||
schemaMsgs.Add($"error: {path} skills[{i}] (root): schema validation failed");
|
||||
|
||||
errors.AddRange(schemaMsgs);
|
||||
}
|
||||
|
||||
var rowSchemaErrors = schemaMsgs.Count;
|
||||
|
||||
var sid = (rowObj["id"] as JsonValue)?.GetValue<string>();
|
||||
if (sid is not null && rowSchemaErrors == 0)
|
||||
{
|
||||
if (skillIdToSourceFile.TryGetValue(sid, out var prevPath))
|
||||
{
|
||||
errors.Add($"error: duplicate skill id '{sid}' in {prevPath} and {path}");
|
||||
continue;
|
||||
}
|
||||
|
||||
skillIdToSourceFile[sid] = path;
|
||||
|
||||
var category = (rowObj["category"] as JsonValue)?.GetValue<string>();
|
||||
if (category is not null)
|
||||
skillIdToCategory[sid] = category;
|
||||
|
||||
rows[sid] = ParseRow(rowObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThrowIfAny(errors);
|
||||
|
||||
var slice1 = PrototypeSlice1SkillCatalogRules.TryGetSlice1GateError(skillIdToSourceFile, skillIdToCategory);
|
||||
if (slice1 is not null)
|
||||
{
|
||||
errors.Add(slice1);
|
||||
ThrowIfAny(errors);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Loaded skill catalog from {SkillsDirectory}: {SkillCount} skill(s) across {CatalogFileCount} JSON catalog file(s).",
|
||||
skillsDirectory,
|
||||
rows.Count,
|
||||
jsonFiles.Length);
|
||||
|
||||
return new SkillDefinitionCatalog(skillsDirectory, rows, jsonFiles.Length);
|
||||
}
|
||||
|
||||
private static SkillDefRow ParseRow(JsonObject rowObj)
|
||||
{
|
||||
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
|
||||
var category = (rowObj["category"] as JsonValue)!.GetValue<string>();
|
||||
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||
var kindsArray = rowObj["allowedXpSourceKinds"] as JsonArray
|
||||
?? throw new InvalidOperationException("allowedXpSourceKinds missing");
|
||||
var kinds = new List<string>();
|
||||
foreach (var n in kindsArray)
|
||||
{
|
||||
if (n is JsonValue v)
|
||||
kinds.Add(v.GetValue<string>());
|
||||
}
|
||||
|
||||
return new SkillDefRow(id, category, displayName, kinds);
|
||||
}
|
||||
|
||||
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
|
||||
{
|
||||
var sink = new List<string>();
|
||||
AppendSchemaMessages(eval, filePath, index, sink);
|
||||
return sink;
|
||||
}
|
||||
|
||||
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
|
||||
{
|
||||
if (r.HasDetails)
|
||||
{
|
||||
foreach (var d in r.Details!)
|
||||
AppendSchemaMessages(d, filePath, index, 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} skills[{index}] {loc}: {kv.Key} — {kv.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfAny(List<string> errors)
|
||||
{
|
||||
if (errors.Count == 0)
|
||||
return;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Skill catalog validation failed:");
|
||||
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
|
||||
sb.AppendLine(e);
|
||||
|
||||
throw new InvalidOperationException(sb.ToString().TrimEnd());
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonSchema.Net" Version="7.4.0" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using NeonSprawl.Server.Game.AbilityInput;
|
||||
using NeonSprawl.Server.Game.Interaction;
|
||||
using NeonSprawl.Server.Game.PositionState;
|
||||
using NeonSprawl.Server.Game.Skills;
|
||||
using NeonSprawl.Server.Game.Targeting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
|
@ -8,8 +9,10 @@ builder.Services.AddPositionStateStore(builder.Configuration);
|
|||
builder.Services.AddHotbarLoadoutStore(builder.Configuration);
|
||||
builder.Services.AddAbilityCooldownStore();
|
||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
||||
|
||||
app.MapGet("/", () => Results.Text(
|
||||
"Neon Sprawl game server — GET /health for JSON status.",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Content": {
|
||||
"SkillsDirectory": "",
|
||||
"SkillDefSchemaPath": ""
|
||||
},
|
||||
"Game": {
|
||||
"DevPlayerId": "dev-local-1",
|
||||
"DefaultPosition": {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ NEON_SPRAWL_API_LOG=1 dotnet run --project server/NeonSprawl.Server/NeonSprawl.S
|
|||
|
||||
IDE: use launch profile **`http+apiLog`** (same URL as **`http`**, sets the variable).
|
||||
|
||||
## Skill catalog (`content/skills`, NEO-34)
|
||||
|
||||
On startup the host loads every **`*.json`** under the skills directory, validates each row against **`content/schemas/skill-def.schema.json`**, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback.
|
||||
|
||||
| Config | Meaning |
|
||||
|--------|---------|
|
||||
| **`Content:SkillsDirectory`** | Optional. Absolute path, or path relative to the server **content root** (the project directory for `dotnet run`). When unset, the host walks **ancestors of `AppContext.BaseDirectory`** until it finds a **`content/skills`** directory (works for `dotnet run` from `server/NeonSprawl.Server` without extra config). |
|
||||
| **`Content:SkillDefSchemaPath`** | Optional override for **`skill-def.schema.json`**. When unset, resolved as **`{parent of skills directory}/schemas/skill-def.schema.json`** (repo layout: `content/schemas` next to `content/skills`). |
|
||||
|
||||
**Docker / CI:** mount or copy the repo **`content/`** tree (at least **`content/skills`** and **`content/schemas`**) and set **`Content__SkillsDirectory`** / **`Content__SkillDefSchemaPath`** if the layout differs from the default discovery rule.
|
||||
|
||||
On success, **Information** logs include the resolved skills directory path and distinct skill count.
|
||||
|
||||
## Position persistence (NEO-8)
|
||||
|
||||
When **`ConnectionStrings:NeonSprawl`** is set to a valid PostgreSQL connection string, the server uses the **`player_position`** table (relational columns `pos_x` … `sequence`, not JSONB). DDL lives in [`server/db/migrations/V001__player_position.sql`](../db/migrations/V001__player_position.sql); the app applies that script on startup and on first store use (idempotent `CREATE TABLE IF NOT EXISTS`). The configured dev player (`Game:DevPlayerId` / `Game:DefaultPosition`) is seeded once at host startup, matching the in-memory store’s constructor seed (not on every HTTP request).
|
||||
|
|
|
|||
Loading…
Reference in New Issue