chore: Epic 2 E2.M1 stub, content CI validation, doc follow-ups

- epic_02: expand E2.M1 module breakdown (non-combat SkillDef, allowedXpSourceKinds)
- PR gate: Python jsonschema validate content/skills vs skill-def.schema.json
- Add scripts/validate_content.py, requirements-content.txt; ignore .venv-content
- CT.M1, E2.M1, internal-content-authoring, content README: document CI spine
- Update 2026-04-03 branch review (suggestions 2–3 done)
pull/17/head
VinPropane 2026-04-03 21:27:52 -04:00
parent f9de62b563
commit d11641159d
10 changed files with 119 additions and 7 deletions

View File

@ -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"

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ client/.godot/
# Local secrets
.env
.env.*.local
# Content validation (optional local venv)
.venv-content/

View File

@ -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
```

View File

@ -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

View File

@ -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)

View File

@ -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.).

View File

@ -54,10 +54,13 @@ Neon Sprawl is **data-driven** for skills, items, recipes, quests, and (eventual
## Phased roadmap (suggested)
<a id="phase-0-contracts-validation"></a>
### 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

View File

@ -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.

View File

@ -0,0 +1,2 @@
# Used by scripts/validate_content.py and PR gate CI (SkillDef + future catalogs).
jsonschema>=4.22.0,<5

View File

@ -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())