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
parent
f9de62b563
commit
d11641159d
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -22,3 +22,6 @@ client/.godot/
|
|||
# Local secrets
|
||||
.env
|
||||
.env.*.local
|
||||
|
||||
# Content validation (optional local venv)
|
||||
.venv-content/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# Used by scripts/validate_content.py and PR gate CI (SkillDef + future catalogs).
|
||||
jsonschema>=4.22.0,<5
|
||||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue