#!/usr/bin/env python3 """Validate content YAML files against their JSON schemas and cross-references. Pass 1: Schema validation (per-file against JSON Schema) Pass 2: Cross-reference validation (9 checks across all files) Schema mapping is by directory context: campaign.yaml -> campaign.schema.json system.yaml -> system.schema.json station.yaml -> station.schema.json district.yaml -> district.schema.json npcs/*.yaml -> npc-profile.schema.json locations/*.yaml -> location.schema.json triangles/*.yaml -> triangle.schema.json dialogue/**/*.yaml -> dialogue-pool.schema.json monologue/**/*.yaml -> monologue-pool.schema.json routines/*.yaml -> routine.schema.json Files under global/ and content.yaml are skipped (no schema yet). Exit code 0 = all valid, 1 = validation errors found. """ import json import re import sys from pathlib import Path import jsonschema import yaml CONTENT_DIR = Path(__file__).resolve().parent.parent / "server" / "content" SCHEMA_DIR = CONTENT_DIR / "_schema" # Map directory parent name (or filename) to schema file FILENAME_SCHEMAS = { "campaign.yaml": "campaign.schema.json", "system.yaml": "system.schema.json", "station.yaml": "station.schema.json", "district.yaml": "district.schema.json", } DIR_SCHEMAS = { "npcs": "npc-profile.schema.json", "locations": "location.schema.json", "triangles": "triangle.schema.json", "dialogue": "dialogue-pool.schema.json", "monologue": "monologue-pool.schema.json", "routines": "routine.schema.json", } def resolve_schema(yaml_path: Path) -> Path | None: """Determine which schema applies to a content YAML file.""" name = yaml_path.name if name in FILENAME_SCHEMAS: return SCHEMA_DIR / FILENAME_SCHEMAS[name] # Walk up parents to find a matching directory name rel = yaml_path.relative_to(CONTENT_DIR) for part in reversed(rel.parts[:-1]): if part in DIR_SCHEMAS: return SCHEMA_DIR / DIR_SCHEMAS[part] return None def _rel(path: Path) -> str: """Return path relative to CONTENT_DIR for display.""" try: return str(path.relative_to(CONTENT_DIR)) except ValueError: return str(path) def _load_yaml(path: Path) -> dict | list | None: """Load a YAML file, returning None for empty/comment-only files.""" try: with open(path) as f: return yaml.safe_load(f) except yaml.YAMLError: return None def _district_dir(path: Path) -> Path | None: """Find the district directory containing this file.""" parts = path.resolve().parts for i, part in enumerate(parts): if part == "districts" and i + 1 < len(parts): return Path(*parts[: i + 2]) return None class ContentIndex: """Builds an index of all defined entities for cross-referencing.""" def __init__(self, campaigns_dir: Path): self.campaigns_dir = campaigns_dir self.npcs: dict[str, Path] = {} # canonical_id -> defining file self.locations: dict[str, set] = {} # district_dir str -> set of location slugs self.triangle_slugs: dict[str, set] = {} # district_dir str -> set of triangle file stems self.fact_ids: set[str] = set() # from knowledge catalogs self.dialogue_files: list[tuple[Path, dict]] = [] self.monologue_files: list[tuple[Path, dict]] = [] self.npc_files: list[tuple[Path, dict]] = [] self.district_files: list[tuple[Path, dict]] = [] def build(self): """Scan all content files and populate the index.""" self._scan_npcs() self._scan_districts() self._scan_triangles() self._scan_knowledge() self._scan_dialogue() self._scan_monologue() def _scan_npcs(self): for path in sorted(self.campaigns_dir.rglob("npcs/*.yaml")): data = _load_yaml(path) if data and isinstance(data, dict): self.npc_files.append((path, data)) cid = data.get("canonical_id") if cid: self.npcs[cid] = path def _scan_districts(self): for path in sorted(self.campaigns_dir.rglob("district.yaml")): data = _load_yaml(path) if data and isinstance(data, dict): self.district_files.append((path, data)) district_dir = str(path.parent.resolve()) locs = data.get("locations", []) if isinstance(locs, list): self.locations[district_dir] = set(locs) def _scan_triangles(self): for path in sorted(self.campaigns_dir.rglob("triangles/*.yaml")): data = _load_yaml(path) if data and isinstance(data, dict): district_dir = str(path.parent.parent.resolve()) if district_dir not in self.triangle_slugs: self.triangle_slugs[district_dir] = set() self.triangle_slugs[district_dir].add(path.stem) def _scan_knowledge(self): knowledge_dir = CONTENT_DIR / "global" / "knowledge" if not knowledge_dir.exists(): return for path in sorted(knowledge_dir.glob("*.yaml")): if path.name == "entity-attributes.yaml": continue data = _load_yaml(path) if not data or not isinstance(data, dict): # Stub catalogs (comment-only or flat key-value) don't parse # as dicts. Fall back to regex extraction of fact_id lines. try: with open(path) as f: for line in f: line = line.strip() if line.startswith("#") or not line: continue m = re.match(r"fact_id:\s*[\"']?([^\"'#]+)", line) if m: self.fact_ids.add(m.group(1).strip()) except OSError: pass continue # Handle structured catalogs self._extract_fact_ids_from_dict(data) def _extract_fact_ids_from_dict(self, data, prefix=""): """Recursively extract fact_id values from knowledge catalog structures.""" if isinstance(data, dict): for key, val in data.items(): if key == "fact_id" and isinstance(val, str): self.fact_ids.add(val) elif isinstance(val, (dict, list)): self._extract_fact_ids_from_dict(val) elif isinstance(data, list): for item in data: self._extract_fact_ids_from_dict(item) def _scan_dialogue(self): for path in sorted(self.campaigns_dir.rglob("dialogue/**/*.yaml")): if path.name.startswith("."): continue data = _load_yaml(path) if data and isinstance(data, dict): self.dialogue_files.append((path, data)) def _scan_monologue(self): for path in sorted(self.campaigns_dir.rglob("monologue/**/*.yaml")): if path.name.startswith("."): continue data = _load_yaml(path) if data and isinstance(data, dict): self.monologue_files.append((path, data)) def validate_references(self) -> tuple[int, int]: """Check all cross-references. Returns (error_count, warning_count).""" errors = 0 warnings = 0 errors += self._check_1_canonical_id_uniqueness() errors += self._check_2_relationship_targets() errors += self._check_3_location_slugs() errors += self._check_4_dialogue_locations() errors += self._check_5_fact_ids() errors += self._check_6_triangle_membership() warnings += self._check_7_npc_count() errors += self._check_8_dialogue_line_ids() warnings += self._check_9_bidirectional_relationships() return errors, warnings def _check_1_canonical_id_uniqueness(self) -> int: """Check that canonical_id values are unique across all NPC files.""" errors = 0 seen: dict[str, Path] = {} for path, data in self.npc_files: cid = data.get("canonical_id") if not cid: continue if cid in seen: print(f'XREF ERROR: duplicate canonical_id "{cid}"') print(f" Defined in: {_rel(seen[cid])}") print(f" Duplicate in: {_rel(path)}") errors += 1 else: seen[cid] = path return errors def _check_2_relationship_targets(self) -> int: """Check that all relationship targets resolve to known canonical_ids.""" errors = 0 for path, data in self.npc_files: rels = data.get("relationships", []) if not isinstance(rels, list): continue for rel in rels: if not isinstance(rel, dict): continue target = rel.get("target") if target and target not in self.npcs: known = sorted(self.npcs.keys()) print(f'XREF ERROR: unresolved relationship target "{target}"') print(f" In: {_rel(path)}") print(f" Known canonical_ids: {', '.join(known[:10])}" + (f" (and {len(known) - 10} more)" if len(known) > 10 else "")) errors += 1 return errors def _check_3_location_slugs(self) -> int: """Check that district location slugs correspond to actual location files.""" errors = 0 for path, data in self.district_files: locs = data.get("locations", []) if not isinstance(locs, list): continue locations_dir = path.parent / "locations" for slug in locs: expected = locations_dir / f"{slug}.yaml" if not expected.exists(): print(f'XREF ERROR: location slug "{slug}" not found') print(f" In: {_rel(path)}") print(f" Expected file: {_rel(expected)}") errors += 1 return errors def _check_4_dialogue_locations(self) -> int: """Check that dialogue file locations match district locations.""" errors = 0 for path, data in self.dialogue_files: location = data.get("location") if not location: continue district_dir = _district_dir(path) if not district_dir: continue district_key = str(district_dir) district_locs = self.locations.get(district_key, set()) if not district_locs: print(f'XREF ERROR: dialogue location "{location}" references district with no locations declared') print(f" In: {_rel(path)}") print(f" District: {_rel(district_dir / 'district.yaml')}") errors += 1 elif location not in district_locs: print(f'XREF ERROR: dialogue location "{location}" not in district') print(f" In: {_rel(path)}") print(f" District locations: {', '.join(sorted(district_locs))}") errors += 1 return errors def _check_5_fact_ids(self) -> int: """Check that all referenced fact_ids resolve to knowledge catalogs.""" if not self.fact_ids: print(" Check 5 (fact_ids): SKIPPED — no canonical fact_ids in knowledge catalogs yet") return 0 errors = 0 # Collect all fact_id references from dialogue, monologue, NPC, and item files refs = self._collect_all_fact_id_refs() for fact_id, sources in sorted(refs.items()): if fact_id not in self.fact_ids: print(f'XREF ERROR: unknown fact_id "{fact_id}"') for src in sources[:3]: print(f" In: {_rel(src)}") if len(sources) > 3: print(f" ...and {len(sources) - 3} more files") print(f" Canonical fact_ids: {len(self.fact_ids)} defined") errors += 1 return errors def _collect_all_fact_id_refs(self) -> dict[str, list[Path]]: """Collect all fact_id references from content files.""" refs: dict[str, list[Path]] = {} def add_ref(fact_id: str, path: Path): if fact_id: refs.setdefault(fact_id, []).append(path) # Dialogue: lines[].knowledge_grant.fact_id for path, data in self.dialogue_files: for line in data.get("lines", []): if not isinstance(line, dict): continue kg = line.get("knowledge_grant") if isinstance(kg, dict): add_ref(kg.get("fact_id", ""), path) # Monologue: entries with prerequisites.facts[].fact_id for path, data in self.monologue_files: self._walk_fact_id_refs(data, path, add_ref) # NPC: information.knows[] for path, data in self.npc_files: info = data.get("information", {}) if isinstance(info, dict): knows = info.get("knows", []) if isinstance(knows, list): for fid in knows: if isinstance(fid, str): add_ref(fid, path) # Items: scan for fact_id in any nested structure for path in sorted(self.campaigns_dir.rglob("items/*.yaml")): data = _load_yaml(path) if data: self._walk_fact_id_refs(data, path, add_ref) return refs def _walk_fact_id_refs(self, data, path: Path, add_ref): """Recursively find fact_id references in nested structures.""" if isinstance(data, dict): if "fact_id" in data and isinstance(data["fact_id"], str): add_ref(data["fact_id"], path) for val in data.values(): if isinstance(val, (dict, list)): self._walk_fact_id_refs(val, path, add_ref) elif isinstance(data, list): for item in data: if isinstance(item, (dict, list)): self._walk_fact_id_refs(item, path, add_ref) def _check_6_triangle_membership(self) -> int: """Check that NPC triangle_membership slugs correspond to triangle files.""" errors = 0 for path, data in self.npc_files: memberships = data.get("triangle_membership", []) if not isinstance(memberships, list): continue district_dir = _district_dir(path) if not district_dir: continue district_key = str(district_dir) available = self.triangle_slugs.get(district_key, set()) for slug in memberships: if not isinstance(slug, str): continue if slug not in available: print(f'XREF ERROR: triangle "{slug}" not found') print(f" In: {_rel(path)}") if available: print(f" Available triangles: {', '.join(sorted(available))}") else: print(f" No triangles found in {_rel(district_dir / 'triangles')}") errors += 1 return errors def _check_7_npc_count(self) -> int: """Check that district npc_count matches actual NPC file count.""" warnings = 0 for path, data in self.district_files: declared = data.get("npc_count") if declared is None: continue npcs_dir = path.parent / "npcs" if not npcs_dir.exists(): continue actual = len(list(npcs_dir.glob("*.yaml"))) if declared != actual: print(f"XREF WARNING: npc_count mismatch") print(f" Declared: {declared}") print(f" Actual NPC files: {actual}") print(f" In: {_rel(path)}") warnings += 1 return warnings def _check_8_dialogue_line_ids(self) -> int: """Check that dialogue line IDs are unique across all dialogue files.""" errors = 0 # Cross-file uniqueness: same line ID in two files is an error global_seen: dict[str, Path] = {} for path, data in self.dialogue_files: lines = data.get("lines", []) if not isinstance(lines, list): continue local_seen: dict[str, int] = {} for i, line in enumerate(lines, 1): if not isinstance(line, dict): continue line_id = line.get("id") if not line_id: continue # Per-file duplicate if line_id in local_seen: print(f'XREF ERROR: duplicate dialogue line id "{line_id}"') print(f" In: {_rel(path)}") print(f" First: line {local_seen[line_id]}") print(f" Duplicate: line {i}") errors += 1 else: local_seen[line_id] = i # Cross-file duplicate if line_id in global_seen and global_seen[line_id] != path: print(f'XREF ERROR: dialogue line id "{line_id}" used in multiple files') print(f" First: {_rel(global_seen[line_id])}") print(f" Also in: {_rel(path)}") errors += 1 elif line_id not in global_seen: global_seen[line_id] = path return errors def _check_9_bidirectional_relationships(self) -> int: """Check that NPC relationships have reciprocal entries. WARNING severity: asymmetric relationships are valid by design (D-034 allows one-directional awareness). This check flags them for review, not as errors.""" warnings = 0 # Build map: canonical_id -> set of relationship targets npc_rels: dict[str, set[str]] = {} for _path, data in self.npc_files: cid = data.get("canonical_id") if not cid: continue rels = data.get("relationships", []) if isinstance(rels, list): targets = set() for rel in rels: if isinstance(rel, dict) and "target" in rel: targets.add(rel["target"]) npc_rels[cid] = targets # Check reciprocity checked = set() for cid, targets in npc_rels.items(): for target in targets: pair = tuple(sorted([cid, target])) if pair in checked: continue checked.add(pair) if target in self.npcs and cid not in npc_rels.get(target, set()): print(f"XREF WARNING: {cid} has relationship to {target} but {target} has no reciprocal entry") warnings += 1 return warnings def schema_validate(campaigns_dir: Path) -> tuple[int, int, int]: """Pass 1: Schema validation. Returns (validated, skipped, errors).""" errors = 0 validated = 0 skipped = 0 schema_cache: dict[str, dict] = {} yaml_files = sorted(campaigns_dir.rglob("*.yaml")) if not yaml_files: print("No YAML files found under campaigns/", file=sys.stderr) return 0, 0, 1 for yaml_path in yaml_files: schema_path = resolve_schema(yaml_path) if schema_path is None: skipped += 1 continue if not schema_path.exists(): print(f"MISSING SCHEMA: {schema_path.name} for {yaml_path.relative_to(CONTENT_DIR)}") errors += 1 continue schema_key = str(schema_path) if schema_key not in schema_cache: with open(schema_path) as f: schema_cache[schema_key] = json.load(f) schema = schema_cache[schema_key] try: with open(yaml_path) as f: data = yaml.safe_load(f) except yaml.YAMLError as e: print(f"YAML ERROR: {yaml_path.relative_to(CONTENT_DIR)}: {e}") errors += 1 continue if data is None: skipped += 1 continue try: jsonschema.validate(instance=data, schema=schema) validated += 1 except jsonschema.ValidationError as e: rel = yaml_path.relative_to(CONTENT_DIR) print(f"INVALID: {rel}") print(f" Schema: {schema_path.name}") print(f" Error: {e.message}") if e.absolute_path: print(f" Path: {'.'.join(str(p) for p in e.absolute_path)}") errors += 1 return validated, skipped, errors def main() -> int: campaigns_dir = CONTENT_DIR / "campaigns" if not campaigns_dir.exists(): print(f"No campaigns directory at {campaigns_dir}", file=sys.stderr) return 1 # Pass 1: Schema validation validated, skipped, schema_errors = schema_validate(campaigns_dir) print(f"\nPass 1 (schema): {validated} validated, {skipped} skipped, {schema_errors} errors") if schema_errors > 0: print(f"\nSchema validation failed ({schema_errors} errors) -- skipping cross-references") return 1 # Pass 2: Cross-reference validation print("\nPass 2 (cross-references):") index = ContentIndex(campaigns_dir) index.build() xref_errors, xref_warnings = index.validate_references() total_errors = schema_errors + xref_errors print(f"\nValidated {validated} files: {total_errors} errors, {xref_warnings} warnings") return 1 if total_errors > 0 else 0 if __name__ == "__main__": sys.exit(main())