diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 823fd2a..e9afc03 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -18,5 +18,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: scripts/requirements-content.txt + + - name: Validate content catalogs + run: | + pip install -r scripts/requirements-content.txt + python scripts/validate_content.py + - name: Gate run: "true" diff --git a/.gitignore b/.gitignore index 8aa6685..198003a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ client/.godot/ # Local secrets .env .env.*.local + +# Content validation (optional local venv) +.venv-content/ diff --git a/content/README.md b/content/README.md index f286bb1..5be4ccf 100644 --- a/content/README.md +++ b/content/README.md @@ -8,3 +8,10 @@ Data-driven definitions (skills, items, recipes, quests) validated in CI with ** | [`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) | Server load path and hot-reload policy are TBD; keep files versioned here as the source of truth. + +**Validate locally** (same as PR gate): + +```bash +pip install -r scripts/requirements-content.txt +python scripts/validate_content.py +``` diff --git a/docs/decomposition/epics/epic_02_classless_progression.md b/docs/decomposition/epics/epic_02_classless_progression.md index fd11e83..7792336 100644 --- a/docs/decomposition/epics/epic_02_classless_progression.md +++ b/docs/decomposition/epics/epic_02_classless_progression.md @@ -18,8 +18,8 @@ Provide a data-driven **classless non-combat skill** layer: `SkillDef` registry, ### E2.M1 - SkillDefinitionRegistry -- Responsibility: Central catalog for skill metadata, category tags, unlock prerequisites. -- Key contracts: `SkillDef`, `SkillCategory`, `UnlockRequirement` +- Responsibility: Central catalog for **non-combat** `SkillDef` rows: **stable `id`**, **`SkillCategory`** (`gather` / `process` / `make` / `tech`), **`displayName`**, **`allowedXpSourceKinds`** (which `XpGrantEvent.sourceKind` values may grant XP per skill), and unlock prerequisites. **Combat roles** are **gigs**, not `SkillDef`. Detail: [E2.M1 — SkillDefinitionRegistry](../modules/E2_M1_SkillDefinitionRegistry.md), [`content/schemas/skill-def.schema.json`](../../../content/schemas/skill-def.schema.json). +- Key contracts: `SkillDef`, `SkillCategory`, `UnlockRequirement`; prototype schema requires **`allowedXpSourceKinds`** per skill. - Dependencies: None - Stage target: Prototype diff --git a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md index 84758a0..b5ccee1 100644 --- a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md +++ b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md @@ -39,6 +39,10 @@ Own **content schema artifacts**, **automated validation** in CI and locally, an Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hardening. +## 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. + ## Source anchors - Plan: [`internal-content-authoring.md`](../../plans/internal-content-authoring.md) diff --git a/docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md b/docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md index b9b97df..cb6569d 100644 --- a/docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md +++ b/docs/decomposition/modules/E2_M1_SkillDefinitionRegistry.md @@ -55,7 +55,7 @@ Add new enum values only with a **schema version** / migration note in [CT.M1](C ## Acceptance criteria (E2.M1 / Slice 1) -- Every loaded `SkillDef` validates against [`skill-def.schema.json`](../../../content/schemas/skill-def.schema.json) in CI ([CT.M1](CT_M1_ContentValidationPipeline.md)) or at server boot. +- 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. - 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.). diff --git a/docs/plans/internal-content-authoring.md b/docs/plans/internal-content-authoring.md index 5843f67..80dbb8f 100644 --- a/docs/plans/internal-content-authoring.md +++ b/docs/plans/internal-content-authoring.md @@ -54,10 +54,13 @@ Neon Sprawl is **data-driven** for skills, items, recipes, quests, and (eventual ## Phased roadmap (suggested) + + ### Phase 0 — Contracts and validation spine - Publish minimal **JSON Schemas** for `ItemDef`, `RecipeDef`, `QuestDef`, and zone graph payloads; align **one** canonical schema location with [contracts.md](../decomposition/modules/contracts.md) (`contracts/schemas/content/` vs co-located — pick once). - Wire **CI** so `content/**/*.json` cannot merge invalid (Node `ajv` / CLI or `dotnet` validator). +- **Done (partial):** `SkillDef` + `content/skills/*.json` — validated in **PR gate** via [`scripts/validate_content.py`](../../scripts/validate_content.py) + [`scripts/requirements-content.txt`](../../scripts/requirements-content.txt); see [CT.M1 — ContentValidationPipeline](../decomposition/modules/CT_M1_ContentValidationPipeline.md#ci-prototype). - Optional: **dotnet** test that **loads** all content (catches deserialization drift). ### Phase 1 — “Content Studio” MVP diff --git a/docs/reviews/2026-04-03-branch-chore-game-design-docs-vs-main.md b/docs/reviews/2026-04-03-branch-chore-game-design-docs-vs-main.md index 26f6710..6025443 100644 --- a/docs/reviews/2026-04-03-branch-chore-game-design-docs-vs-main.md +++ b/docs/reviews/2026-04-03-branch-chore-game-design-docs-vs-main.md @@ -43,8 +43,8 @@ None for **using the branch as design + schema guidance**. ## Suggestions 1. ~~**Epic 2 Objective** — “gathering, crafting, and combat all feed one coherent progression model.”~~ **Fixed in this review** — now states **skill** vs **gig** split and mission **skill** rewards. -2. **E2.M1 “Module Breakdown” stub** (short `Responsibility` under `### E2.M1` in `epic_02`): still one generic line; optional align to “non-combat `SkillDef`” to mirror the long-form module doc. -3. **CT.M1 / CI:** Schema and prototype JSON are documented; **wire validation** when the content pipeline lands (`docs/plans/internal-content-authoring.md` already points at ajv/CLI). +2. ~~**E2.M1 “Module Breakdown” stub**~~ **Done** — `epic_02` **E2.M1** block matches long-form module + schema links. +3. ~~**CT.M1 / CI:**~~ **Done** — PR gate runs [`scripts/validate_content.py`](../../scripts/validate_content.py) for `content/skills/*.json` vs `skill-def.schema.json`; docs updated ([CT.M1](../decomposition/modules/CT_M1_ContentValidationPipeline.md#ci-prototype), [`internal-content-authoring` Phase 0](../plans/internal-content-authoring.md#phase-0-contracts-validation)). 4. **Decomposition grep:** Periodically run `rg "combat skill|one combat skill"` under `docs/decomposition` after gig modules are drafted to catch regressions. ## Nits @@ -62,5 +62,5 @@ None for **using the branch as design + schema guidance**. ## Follow-up (applied during this review pass) - `epic_02_classless_progression.md` **Objective**: hybrid **skill** vs **gig** wording + links to `progression.md` and E2.M2. - -**Commit:** User should commit the Epic 2 Objective fix separately or fold into the next push (not committed by the review tool). +- `epic_02_classless_progression.md` **E2.M1** module stub: non-combat **`SkillDef`**, categories, **`allowedXpSourceKinds`**, links to E2.M1 module + JSON Schema. +- **Content CI:** `.github/workflows/pr-gate.yml` + `scripts/validate_content.py` + `scripts/requirements-content.txt`; CT.M1 / E2.M1 / `content/README` / internal-content-authoring updates. diff --git a/scripts/requirements-content.txt b/scripts/requirements-content.txt new file mode 100644 index 0000000..0e4776b --- /dev/null +++ b/scripts/requirements-content.txt @@ -0,0 +1,2 @@ +# Used by scripts/validate_content.py and PR gate CI (SkillDef + future catalogs). +jsonschema>=4.22.0,<5 diff --git a/scripts/validate_content.py b/scripts/validate_content.py new file mode 100644 index 0000000..798f87a --- /dev/null +++ b/scripts/validate_content.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Validate content catalogs against JSON Schema (CI + local). + +Today: each object in content/skills/*.json top-level ``skills`` array vs +content/schemas/skill-def.schema.json. Extend this script as new catalogs land. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from jsonschema import Draft202012Validator + +REPO_ROOT = Path(__file__).resolve().parent.parent +SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json" +SKILLS_DIR = REPO_ROOT / "content/skills" + + +def main() -> int: + if not SKILL_SCHEMA.is_file(): + print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr) + return 1 + + schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) + validator = Draft202012Validator(schema) + + if not SKILLS_DIR.is_dir(): + print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) + return 1 + + json_files = sorted(SKILLS_DIR.glob("*.json")) + if not json_files: + print(f"error: no JSON files under {SKILLS_DIR}", file=sys.stderr) + return 1 + + seen_ids: dict[str, str] = {} + errors = 0 + + for path in json_files: + data = json.loads(path.read_text(encoding="utf-8")) + skills = data.get("skills") + if not isinstance(skills, list): + print(f"error: {path.relative_to(REPO_ROOT)}: expected top-level 'skills' array", file=sys.stderr) + errors += 1 + continue + for i, row in enumerate(skills): + if not isinstance(row, dict): + print(f"error: {path.relative_to(REPO_ROOT)}: skills[{i}] must be an object", file=sys.stderr) + errors += 1 + continue + 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, + ) + errors += 1 + sid = row.get("id") + if isinstance(sid, str): + prev = seen_ids.get(sid) + if prev: + print( + f"error: duplicate skill id {sid!r} in {prev} and {path.relative_to(REPO_ROOT)}", + file=sys.stderr, + ) + errors += 1 + else: + seen_ids[sid] = str(path.relative_to(REPO_ROOT)) + + if errors: + print(f"content validation failed with {errors} error(s)", 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 + + +if __name__ == "__main__": + raise SystemExit(main())