#!/usr/bin/env python3 """Deterministically audit the integrated Aetherbound Guild design package.""" from __future__ import annotations import argparse import json import re import sys import tempfile from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple REPOSITORY_ROOT = Path(__file__).resolve().parents[1] AUTHORITY_DIRECTORIES = ( Path("docs/product"), Path("docs/content"), Path("docs/presentation"), ) REQUIRED_FILES = ( Path("docs/product/GAME_PRODUCT_CONTRACT.md"), Path("docs/product/SYSTEMS_AND_BATTLE.md"), Path("docs/product/ECONOMY_AND_BALANCE.md"), Path("docs/product/SAVE_AND_FAILURE_CONTRACT.md"), Path("docs/content/RECRUIT_GENERATION.md"), Path("docs/presentation/SCREEN_AND_STATE_MAP.md"), Path("docs/presentation/UI_AND_VISUAL_SYSTEM.md"), Path("docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md"), Path("docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md"), ) FORBIDDEN_AUTHORITY_FILES = (Path("docs/content/CHARACTER_BIBLE.md"),) EXACT_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+$") ID_IN_TEXT_RE = re.compile( r"(? Tuple[str, int, int, str, str]: return (self.path, self.line, self.column, self.code, self.message) def as_dict(self) -> Dict[str, object]: return { "severity": "error", "code": self.code, "path": self.path, "line": self.line, "column": self.column, "message": self.message, } @dataclass(frozen=True) class Document: path: str text: str @dataclass(frozen=True) class Definition: identifier: str path: str line: int column: int cells: Tuple[str, ...] @dataclass(frozen=True) class Reference: identifiers: Tuple[str, ...] path: str line: int column: int source: str @dataclass class AuditResult: issues: List[Issue] counts: Dict[str, int] @property def ok(self) -> bool: return not self.issues @dataclass(frozen=True) class RegistryRule: name: str path: str pattern: re.Pattern expected_ids: Tuple[str, ...] minimum_cells: int heading_pattern: Optional[re.Pattern] = None @dataclass(frozen=True) class HiddenProfessionContent: identifier: str name: str parents: Tuple[str, ...] mechanic: str production_segments: Tuple[str, ...] line: int @dataclass(frozen=True) class HiddenProfessionArt: identifier: str identity: str parents: Tuple[str, ...] translation: str props: str vfx: str actions: str line: int @dataclass(frozen=True) class RegionContentBinding: identifier: str name: str biome: str hazard: str palette: str line: int @dataclass(frozen=True) class RegionArtBinding: identifier: str targets: Tuple[str, ...] biome: str palette: str hazard: str line: int def numbered_ids(prefix: str, count: int, width: int) -> Tuple[str, ...]: return tuple("{}{:0{}d}".format(prefix, number, width) for number in range(1, count + 1)) REGISTRY_RULES = ( RegistryRule( "professions.base", "docs/content/PROFESSION_CATALOG.md", re.compile(r"^PF-B\d{2}$"), numbered_ids("PF-B", 12, 2), 7, re.compile(r"^Base Professions$"), ), RegistryRule( "professions.advanced", "docs/content/PROFESSION_CATALOG.md", re.compile(r"^PF-A\d{2}$"), numbered_ids("PF-A", 24, 2), 6, re.compile(r"^Regular Advanced Professions$"), ), RegistryRule( "professions.hidden", "docs/content/PROFESSION_CATALOG.md", re.compile(r"^PF-H\d{2}$"), numbered_ids("PF-H", 6, 2), 6, re.compile(r"^Hidden Hybrid Professions$"), ), RegistryRule( "traits", "docs/content/TRAIT_CATALOG.md", re.compile(r"^TR-\d{3}$"), numbered_ids("TR-", 36, 3), 6, ), RegistryRule( "artifacts", "docs/content/ARTIFACT_CATALOG.md", re.compile(r"^AR-\d{3}$"), numbered_ids("AR-", 60, 3), 4, ), RegistryRule( "bosses", "docs/content/ENEMY_CATALOG.md", re.compile(r"^BO-R[1-8]-\d{2}$"), tuple( "BO-R{}-{:02d}".format(region, ordinal) for region in range(1, 9) for ordinal in range(1, 3) ), 5, ), RegistryRule( "regions", "docs/content/REGION_CATALOG.md", re.compile(r"^RG-\d{2}$"), numbered_ids("RG-", 8, 2), 6, ), ) EQUIPMENT_PATH = "docs/content/EQUIPMENT_CATALOG.md" ENEMY_PATH = "docs/content/ENEMY_CATALOG.md" SCREEN_PATH = "docs/presentation/SCREEN_AND_STATE_MAP.md" AUDIO_PATH = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md" PRODUCT_PATH = "docs/product/GAME_PRODUCT_CONTRACT.md" SAVE_PATH = "docs/product/SAVE_AND_FAILURE_CONTRACT.md" RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.md" PROFESSION_PATH = "docs/content/PROFESSION_CATALOG.md" UI_PATH = "docs/presentation/UI_AND_VISUAL_SYSTEM.md" ART_PATH = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md" REGION_PATH = "docs/content/REGION_CATALOG.md" REFERENCE_SCOPE_PATH = "docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md" PAGE_ID_RE = re.compile(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}") HIDDEN_PROFESSION_IDS = numbered_ids("PF-H", 6, 2) REGION_IDS = numbered_ids("RG-", 8, 2) REGION_ART_IDS = numbered_ids("ART-REG-", 8, 2) HIDDEN_ART_UNRESOLVED_RE = re.compile( r"\b(?:unresolved|placeholder|pending|unknown|to\s+be\s+(?:resolved|bound))\b|" r"CONTENT\.PROFESSION\.HIDDEN\[\d{2}\]\.parents\b", re.IGNORECASE, ) SEMANTIC_STOP_WORDS = frozenset( { "a", "action", "an", "and", "art", "as", "at", "be", "both", "by", "content", "cue", "each", "event", "exact", "field", "for", "front", "from", "hand", "in", "into", "is", "it", "its", "mechanic", "new", "of", "on", "one", "only", "or", "owned", "pair", "profession", "rear", "role", "same", "that", "target", "the", "their", "then", "this", "three", "to", "toward", "two", "use", "uses", "when", "with", } ) COUNT_WORDS = { "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "twelve": 12, "sixteen": 16, "twenty": 20, "twenty-four": 24, "thirty-six": 36, "forty-two": 42, } COUNT_TOKEN = r"(?:\d+|" + "|".join(sorted(COUNT_WORDS, key=len, reverse=True)) + r")" PROVISIONAL_SELECTION_RE = re.compile( r"\b(?:choose|select(?:ed|s|ing)?)\s+(?:exactly\s+)?(?P{})\b" r"(?=[^\n|.]{{0,120}}\bprovisional\b)" r"(?=[^\n|.]{{0,120}}\brecruits?\b)" r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P{})\b".format( COUNT_TOKEN, COUNT_TOKEN ), re.IGNORECASE, ) PROVISIONAL_SELECTED_COUNT_RES = ( re.compile( r"\b(?:choose|select)\s+(?:exactly\s+)?(?P{})\b".format(COUNT_TOKEN), re.IGNORECASE, ), re.compile( r"\b(?:with\s+)?(?:exactly\s+)?(?P{})\s+selected\b".format(COUNT_TOKEN), re.IGNORECASE, ), re.compile( r"\bselected\s+(?:exactly\s+)?(?P{})\b".format(COUNT_TOKEN), re.IGNORECASE, ), ) PROVISIONAL_OFFERED_COUNT_RES = ( re.compile( r"\b(?P{})\b(?:\s+[a-z][a-z-]*){{0,6}}\s+offers?\b".format( COUNT_TOKEN ), re.IGNORECASE, ), re.compile( r"\b(?:of|from)\s+(?:the\s+)?(?P{})\b" r"[^\n|.]{{0,100}}\bprovisional\b[^\n|.]{{0,60}}\brecruits?\b".format( COUNT_TOKEN ), re.IGNORECASE, ), ) REVISABLE_SELECTION_RE = re.compile( r"\b(?:revis(?:e|ed|able)|change(?:d|able)?|edit(?:ed|able)?|reselect|undo|uncommitted)\b", re.IGNORECASE, ) BEFORE_RUN_CREATION_RE = re.compile( r"(?:\bbefore\b.{0,60}\brun\s+creat(?:ion|ed?)\b|" r"\buntil\b.{0,60}\brun\s+(?:creation|commit)|" r"\bbefore\s+(?:the\s+)?commit\b|\buntil\s+(?:the\s+)?commit\b|" r"\bno\s+run\s+(?:is\s+)?created\b)", re.IGNORECASE, ) RECRUIT_PART_FIELDS = ( ( "body_frame", re.compile(r"^body\s+frame$", re.IGNORECASE), re.compile(r"\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE), ), ( "face_shape", re.compile(r"^face\s+shape$", re.IGNORECASE), re.compile(r"\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE), ), ( "skin_palette", re.compile(r"^skin\s+palette$", re.IGNORECASE), re.compile(r"\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE), ), ( "hair_shape", re.compile(r"^hair(?:/head)?\s+shape$", re.IGNORECASE), re.compile(r"\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE), ), ( "hair_palette", re.compile(r"^hair\s+palette$", re.IGNORECASE), re.compile(r"\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE), ), ( "garment_cut", re.compile(r"^garment\s+cut$", re.IGNORECASE), re.compile(r"\bgarment\s+cuts?\b|\bunderlayers?\b(?!\s+ramps?)", re.IGNORECASE), ), ( "garment_palette", re.compile(r"^garment\s+palette$", re.IGNORECASE), re.compile(r"\b(?:garment\s+palettes?|underlayer\s+ramps?)\b", re.IGNORECASE), ), ( "profession_overlay", re.compile(r"^profession\s+overlay$", re.IGNORECASE), re.compile(r"\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE), ), ( "detail_mark", re.compile(r"^detail\s+mark$", re.IGNORECASE), re.compile(r"\b(?:face\s+detail\s+sets?|detail\s+marks?)\b", re.IGNORECASE), ), ( "voice_effort_set", re.compile(r"^voice\s+effort\s+set$", re.IGNORECASE), re.compile(r"\b(?:voice\s+effort\s+sets?|audio)\b", re.IGNORECASE), ), ) RECRUIT_ART_BINDING_PATTERNS = { "body_frame": re.compile( r"\bART-REC-BODY-|\bbody\s+(?:foundations?|frames?)\b", re.IGNORECASE ), "face_shape": re.compile( r"\bART-REC-FACE-|\bface\s+(?:structures?|shapes?)\b", re.IGNORECASE ), "skin_palette": re.compile( r"\bART-REC-SKINPAL-|\bskin\s+(?:ramps?|palettes?)\b", re.IGNORECASE ), "hair_shape": re.compile( r"\bART-REC-HAIR-|\bhair\s+(?:famil(?:y|ies)|shapes?)\b", re.IGNORECASE ), "hair_palette": re.compile( r"\bART-REC-HAIRPAL-|\bhair\s+(?:ramps?|palettes?)\b", re.IGNORECASE ), "garment_cut": re.compile( r"\bART-REC-GARMENT-|\b(?:garment\s+cuts?|underlayers?)\b", re.IGNORECASE ), "garment_palette": re.compile( r"\bART-REC-GARMENTPAL-|\b(?:garment\s+palettes?|underlayer\s+ramps?)\b", re.IGNORECASE, ), "profession_overlay": re.compile( r"\bART-PRO-|\bprofession\s+(?:kits?|overlays?)\b", re.IGNORECASE ), "detail_mark": re.compile( r"\bART-REC-DETAIL-|\b(?:face\s+detail\s+sets?|detail\s+marks?)\b", re.IGNORECASE, ), "voice_effort_set": re.compile( r"\b(?:audio|no\s+art|nonvisual)\b", re.IGNORECASE ), } def relative_path(path: Path, root: Path) -> str: try: return path.relative_to(root).as_posix() except ValueError: return path.as_posix() def issue( code: str, path: str, line: int, column: int, message: str, ) -> Issue: return Issue(code, path, max(1, line), max(1, column), message) def split_markdown_row(line: str) -> Optional[Tuple[str, ...]]: stripped = line.strip() if not stripped.startswith("|"): return None cells: List[str] = [] current: List[str] = [] escaped = False in_code = False for character in stripped[1:]: if escaped: current.append(character) escaped = False continue if character == "\\": current.append(character) escaped = True continue if character == "`": in_code = not in_code current.append(character) continue if character == "|" and not in_code: cells.append("".join(current).strip()) current = [] continue current.append(character) if current: cells.append("".join(current).strip()) if cells and not cells[-1]: cells.pop() if not cells: return None return tuple(cells) def first_cell_identifier(cell: str) -> Optional[Tuple[str, int]]: for match in CODE_SPAN_RE.finditer(cell): candidate = match.group(1).strip() if EXACT_ID_RE.fullmatch(candidate) and ( any(character.isdigit() for character in candidate) or NAMED_PRESENTATION_ID_RE.match(candidate) ): return candidate, match.start(1) + 1 return None def collect_documents(root: Path) -> Tuple[List[Document], List[Issue]]: documents: List[Document] = [] issues: List[Issue] = [] for required in REQUIRED_FILES: if not (root / required).is_file(): issues.append( issue("AUTHORITY_FILE_MISSING", required.as_posix(), 1, 1, "required design authority is missing") ) for forbidden in FORBIDDEN_AUTHORITY_FILES: if (root / forbidden).exists(): issues.append( issue( "RETIRED_AUTHORITY_FILE", forbidden.as_posix(), 1, 1, "fixed-character authority must be removed or replaced by generated-recruit catalogs", ) ) for directory in AUTHORITY_DIRECTORIES: absolute = root / directory if not absolute.is_dir(): issues.append( issue("AUTHORITY_DIRECTORY_MISSING", directory.as_posix(), 1, 1, "authority directory is missing") ) continue for path in sorted(absolute.rglob("*.md")): try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeError) as error: issues.append( issue( "AUTHORITY_READ_ERROR", relative_path(path, root), 1, 1, "cannot read UTF-8 Markdown: {}".format(error), ) ) continue documents.append(Document(relative_path(path, root), text)) return documents, issues def heading_text(line: str) -> Optional[str]: match = re.match(r"^#{1,6}\s+(.+?)\s*$", line) return match.group(1) if match else None def is_primary_definition(path: str, heading: str, identifier: str) -> bool: for rule in REGISTRY_RULES: if path != rule.path or not rule.pattern.fullmatch(identifier): continue if rule.heading_pattern is None or rule.heading_pattern.fullmatch(heading): return True if path == EQUIPMENT_PATH and re.fullmatch(r"EQ-\d{3}", identifier): return True if path == ENEMY_PATH and re.fullmatch(r"EN-R[1-8]-\d{2}", identifier): return True if path == SCREEN_PATH and re.fullmatch( r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}", identifier ): return True return False def collect_audio_definitions(document: Document) -> List[Definition]: if document.path != AUDIO_PATH: return [] definitions: List[Definition] = [] heading = "" in_fence = False for line_number, line in enumerate(document.text.splitlines(), 1): found_heading = heading_text(line) if found_heading is not None: heading = found_heading if line.strip().startswith("```"): in_fence = not in_fence continue expressions: List[Tuple[str, int, Tuple[str, ...]]] = [] cells = split_markdown_row(line) if heading == "5.1 Scope And Count" and cells: for match in CODE_SPAN_RE.finditer(cells[0]): expressions.append((match.group(1).strip(), line.find(match.group(1)), cells)) elif heading == "6.1 Configuration Count" and cells and len(cells) >= 2: for match in CODE_SPAN_RE.finditer(cells[1]): expressions.append((match.group(1).strip(), line.find(match.group(1)), cells)) elif heading == "7.1 Bounded Scope" and in_fence: match = re.match(r"\s*(VO-[A-Z0-9-]+)\s+(.+)$", line) if match: expressions.append((match.group(1), match.start(1), (match.group(1), match.group(2)))) elif re.fullmatch(r"8\.[1-7].*", heading) and in_fence: match = re.match(r"\s*(SFX-[A-Z0-9-]+)\s+(.+)$", line) if match: expressions.append((match.group(1), match.start(1), (match.group(1), match.group(2)))) elif heading.startswith("8.8 ") and cells: for cell in cells: for match in CODE_SPAN_RE.finditer(cell): candidate = match.group(1).strip() if candidate.startswith("SFX-"): expressions.append((candidate, line.find(candidate), cells)) for expression, column, source_cells in expressions: expanded = expand_compact_id_expression(expression) if expanded is None: continue for identifier in expanded: if not re.fullmatch(r"(?:MUS|AMB|SFX|VO)-[A-Z0-9-]+", identifier): continue definitions.append( Definition(identifier, document.path, line_number, column + 1, source_cells) ) return definitions def collect_definitions(documents: Sequence[Document]) -> Tuple[List[Definition], List[Issue]]: definitions: List[Definition] = [] issues: List[Issue] = [] for document in documents: heading = "" for line_number, line in enumerate(document.text.splitlines(), 1): found_heading = heading_text(line) if found_heading is not None: heading = found_heading cells = split_markdown_row(line) if not cells: continue found = first_cell_identifier(cells[0]) if not found: continue identifier, cell_column = found if not is_primary_definition(document.path, heading, identifier): continue definitions.append( Definition( identifier, document.path, line_number, max(1, line.find(cells[0]) + cell_column), cells, ) ) definitions.extend(collect_audio_definitions(document)) by_identifier: Dict[str, List[Definition]] = {} for definition in definitions: by_identifier.setdefault(definition.identifier, []).append(definition) for identifier, locations in sorted(by_identifier.items()): if len(locations) < 2: continue rendered = ", ".join("{}:{}".format(item.path, item.line) for item in locations) for location in locations: issues.append( issue( "ID_DUPLICATE", location.path, location.line, location.column, "{} is defined {} times ({})".format(identifier, len(locations), rendered), ) ) return definitions, issues def format_id_sample(identifiers: Iterable[str], limit: int = 12) -> str: values = sorted(set(identifiers)) if len(values) <= limit: return ", ".join(values) return "{}, ... (+{} more)".format(", ".join(values[:limit]), len(values) - limit) def has_pending_marker(value: str) -> bool: return any(pattern.search(value) for pattern in PENDING_PATTERNS) def validate_registry_rule( rule: RegistryRule, definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int], ) -> Set[str]: matches = [ definition for definition in definitions if definition.path == rule.path and rule.pattern.fullmatch(definition.identifier) ] actual_ids = {definition.identifier for definition in matches} expected_ids = set(rule.expected_ids) counts[rule.name] = len(actual_ids) if actual_ids != expected_ids: missing = expected_ids - actual_ids extra = actual_ids - expected_ids detail: List[str] = [] if missing: detail.append("missing [{}]".format(format_id_sample(missing))) if extra: detail.append("unexpected [{}]".format(format_id_sample(extra))) issues.append( issue( "REGISTRY_EXACT_SET", "docs/content", 1, 1, "{} requires exactly {} stable IDs; found {}{}".format( rule.name, len(expected_ids), len(actual_ids), "; " + "; ".join(detail) if detail else "", ), ) ) for definition in matches: if len(definition.cells) < rule.minimum_cells: issues.append( issue( "REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} row has {} cells; at least {} nonempty structural cells are required".format( definition.identifier, len(definition.cells), rule.minimum_cells ), ) ) if any(not cell.strip() for cell in definition.cells[: rule.minimum_cells]): issues.append( issue( "REGISTRY_ROW_EMPTY", definition.path, definition.line, definition.column, "{} has an empty required structural cell".format(definition.identifier), ) ) if any(has_pending_marker(cell) for cell in definition.cells): issues.append( issue( "REGISTRY_ROW_PENDING", definition.path, definition.line, definition.column, "{} contains unresolved or filler content".format(definition.identifier), ) ) return actual_ids def validate_equipment_registry( definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> Set[str]: equipment_definitions = [ definition for definition in definitions if definition.path == EQUIPMENT_PATH and re.fullmatch(r"EQ-\d{3}", definition.identifier) ] actual_ids = {definition.identifier for definition in equipment_definitions} expected_ids = set(numbered_ids("EQ-", 320, 3)) counts["equipment"] = len(actual_ids) if actual_ids != expected_ids: missing = expected_ids - actual_ids extra = actual_ids - expected_ids detail: List[str] = [] if missing: detail.append("missing [{}]".format(format_id_sample(missing))) if extra: detail.append("unexpected [{}]".format(format_id_sample(extra))) issues.append( issue( "REGISTRY_EXACT_SET", "docs/content", 1, 1, "equipment requires exactly EQ-001..EQ-320; found {}{}".format( len(actual_ids), "; " + "; ".join(detail) if detail else "", ), ) ) for definition in equipment_definitions: if len(definition.cells) < 6 or any(not cell.strip() for cell in definition.cells[:6]): issues.append( issue( "REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} requires six nonempty equipment-schema cells".format(definition.identifier), ) ) if any(has_pending_marker(cell) for cell in definition.cells): issues.append( issue( "REGISTRY_ROW_PENDING", definition.path, definition.line, definition.column, "{} contains unresolved or filler content".format(definition.identifier), ) ) return actual_ids def validate_enemy_registry( definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> Set[str]: matches = [ definition for definition in definitions if definition.path == ENEMY_PATH and re.fullmatch(r"EN-R[1-8]-\d{2}", definition.identifier) ] actual_ids = {definition.identifier for definition in matches} counts["enemies.ordinary_elite"] = len(actual_ids) if len(actual_ids) != 100: issues.append( issue( "REGISTRY_EXACT_COUNT", "docs/content", 1, 1, "ordinary/elite enemies require exactly 100 EN-Rr-nn IDs; found {}".format( len(actual_ids) ), ) ) for region in range(1, 9): prefix = "EN-R{}-".format(region) numbers = sorted( int(identifier[-2:]) for identifier in actual_ids if identifier.startswith(prefix) ) if not numbers: issues.append( issue( "ENEMY_REGION_EMPTY", "docs/content", 1, 1, "{} has no ordinary/elite enemy definitions".format(prefix.rstrip("-")), ) ) continue expected = set(range(1, max(numbers) + 1)) missing = expected - set(numbers) if missing: issues.append( issue( "ENEMY_SEQUENCE_GAP", "docs/content", 1, 1, "{} numbering must be gap-free from 01; missing [{}]".format( prefix.rstrip("-"), format_id_sample("{}{:02d}".format(prefix, number) for number in missing), ), ) ) for definition in matches: if len(definition.cells) < 5 or any(not cell.strip() for cell in definition.cells[:5]): issues.append( issue( "REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} requires five nonempty enemy-schema cells".format(definition.identifier), ) ) if len(definition.cells) >= 2 and not re.search( r"\b(?:ordinary|elite)\b", definition.cells[1], re.IGNORECASE ): issues.append( issue( "ENEMY_CLASS_MISSING", definition.path, definition.line, definition.column, "{} must declare ordinary or elite class".format(definition.identifier), ) ) if any(has_pending_marker(cell) for cell in definition.cells): issues.append( issue( "REGISTRY_ROW_PENDING", definition.path, definition.line, definition.column, "{} contains unresolved or filler content".format(definition.identifier), ) ) return actual_ids def parse_range(value: str) -> Optional[Tuple[str, ...]]: match = RANGE_IN_TEXT_RE.fullmatch(value) if not match: return None left, right = match.groups() left_match = re.fullmatch(r"(.*?)(\d+)", left) right_match = re.fullmatch(r"(.*?)(\d+)(-[A-Z][A-Z0-9-]*)?", right) if not left_match or not right_match: return None left_prefix, left_number_text = left_match.groups() right_prefix, right_number_text, suffix = right_match.groups() suffix = suffix or "" compatible_prefix = not right_prefix or right_prefix == left_prefix if right_prefix and left_prefix.rstrip("-").endswith(right_prefix.rstrip("-")): compatible_prefix = True if not compatible_prefix: right_identifier = "{}{}{}".format(right_prefix, right_number_text, suffix) if EXACT_ID_RE.fullmatch(left) and EXACT_ID_RE.fullmatch(right_identifier): return (left, right_identifier) return None start = int(left_number_text) end = int(right_number_text) if end < start or end - start > 1000: return None width = max(len(left_number_text), len(right_number_text)) return tuple( "{}{:0{}d}{}".format(left_prefix, number, width, suffix) for number in range(start, end + 1) ) def expand_compact_id_expression(value: str) -> Optional[Tuple[str, ...]]: value = value.strip() brace = re.search(r"\{(\d+)\.\.(\d+)\}", value) if brace: start_text, end_text = brace.groups() start = int(start_text) end = int(end_text) if end < start or end - start > 1000: return None expanded: List[str] = [] width = max(len(start_text), len(end_text)) for number in range(start, end + 1): nested = value[: brace.start()] + "{:0{}d}".format(number, width) + value[brace.end() :] nested_values = expand_compact_id_expression(nested) if nested_values is None: return None expanded.extend(nested_values) return tuple(expanded) if ".." in value: return parse_range(value) if EXACT_ID_RE.fullmatch(value): return (value,) return None def looks_like_id_range(value: str) -> bool: return ( ".." in value and bool(value) and value[0].isupper() and "-" in value and re.fullmatch(r"[A-Z0-9{}.-]+", value) is not None ) def collect_references(documents: Sequence[Document]) -> Tuple[List[Reference], List[Issue]]: references: List[Reference] = [] issues: List[Issue] = [] for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): masked = list(line) for match in CODE_SPAN_RE.finditer(line): expression = match.group(1).strip() if ".." not in expression: continue if BRACKET_BINDING_RE.fullmatch(expression): for index in range(match.start(), match.end()): masked[index] = " " continue if not looks_like_id_range(expression): continue expanded = expand_compact_id_expression(expression) if expanded is None: issues.append( issue( "REFERENCE_RANGE_INVALID", document.path, line_number, match.start(1) + 1, "invalid or unbounded ID range {}".format(expression), ) ) else: references.append( Reference(expanded, document.path, line_number, match.start(1) + 1, expression) ) for index in range(match.start(), match.end()): masked[index] = " " masked_after_code = "".join(masked) for match in RANGE_IN_TEXT_RE.finditer(masked_after_code): expanded = expand_compact_id_expression(match.group(0)) if expanded is None: issues.append( issue( "REFERENCE_RANGE_INVALID", document.path, line_number, match.start() + 1, "invalid or unbounded ID range {}".format(match.group(0)), ) ) else: references.append( Reference(expanded, document.path, line_number, match.start() + 1, match.group(0)) ) for index in range(match.start(), match.end()): masked[index] = " " masked_line = "".join(masked) for match in ID_IN_TEXT_RE.finditer(masked_line): references.append( Reference( (match.group(1),), document.path, line_number, match.start(1) + 1, match.group(1), ) ) return references, issues def numeric_stem(identifier: str) -> Optional[str]: match = re.fullmatch(r"(.*?)(\d+)", identifier) return match.group(1) if match else None def validate_cross_references( definitions: Sequence[Definition], references: Sequence[Reference], issues: List[Issue] ) -> None: defined_ids = {definition.identifier for definition in definitions} known_stems = { stem for stem in (numeric_stem(identifier) for identifier in defined_ids) if stem is not None } known_stems.update( ( "PF-B", "PF-A", "PF-H", "TR-", "EQ-", "AR-", "EN-R1-", "EN-R2-", "EN-R3-", "EN-R4-", "EN-R5-", "EN-R6-", "EN-R7-", "EN-R8-", "BO-R1-", "BO-R2-", "BO-R3-", "BO-R4-", "BO-R5-", "BO-R6-", "BO-R7-", "BO-R8-", "RG-", "PR-B", "PR-A", "PR-H", "IT-", "EN-O", "EN-E", "EN-B", "REG-", ) ) observed: Set[Tuple[str, int, str, Tuple[str, ...]]] = set() for reference in references: missing = tuple( identifier for identifier in reference.identifiers if identifier not in defined_ids and numeric_stem(identifier) in known_stems ) if not missing: continue key = (reference.path, reference.line, reference.source, missing) if key in observed: continue observed.add(key) issues.append( issue( "REFERENCE_BROKEN", reference.path, reference.line, reference.column, "{} references undefined ID(s): {}".format( reference.source, format_id_sample(missing) ), ) ) def validate_declared_bindings( documents: Sequence[Document], issues: List[Issue], counts: Dict[str, int] ) -> None: definitions: Dict[str, List[Tuple[str, int, int]]] = {} references: List[Tuple[str, str, int, int]] = [] for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): for match in BINDING_ID_RE.finditer(line): references.append((match.group(0), document.path, line_number, match.start() + 1)) if document.path not in (SCREEN_PATH, "docs/presentation/UI_AND_VISUAL_SYSTEM.md"): continue cells = split_markdown_row(line) if not cells: continue match = re.search(r"`(BIND:[A-Z][A-Z0-9_]+)`", cells[0]) if not match: continue binding_id = match.group(1) definitions.setdefault(binding_id, []).append( (document.path, line_number, line.find(binding_id) + 1) ) if len(cells) < 3 or any(not cell.strip() for cell in cells[:3]): issues.append( issue( "BINDING_SCHEMA_INVALID", document.path, line_number, line.find(binding_id) + 1, "{} requires binding, use, and owner/source cells".format(binding_id), ) ) counts["bindings.declared"] = len(definitions) for binding_id, locations in sorted(definitions.items()): if len(locations) < 2: continue rendered = ", ".join("{}:{}".format(path, line) for path, line, _ in locations) for path, line, column in locations: issues.append( issue( "BINDING_DUPLICATE", path, line, column, "{} is declared {} times ({})".format(binding_id, len(locations), rendered), ) ) seen_unknown: Set[Tuple[str, str, int]] = set() for binding_id, path, line, column in references: if binding_id in definitions: continue key = (binding_id, path, line) if key in seen_unknown: continue seen_unknown.add(key) issues.append( issue( "BINDING_UNKNOWN", path, line, column, "{} has no declaration in the presentation binding registries".format(binding_id), ) ) def validate_pending_bindings(documents: Sequence[Document], issues: List[Issue]) -> None: observed: Set[Tuple[str, int, int, str]] = set() observed_bracket_bindings: Set[str] = set() for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): for match in BRACKET_BINDING_RE.finditer(line): raw = match.group(0) if raw in observed_bracket_bindings: continue observed_bracket_bindings.add(raw) issues.append( issue( "BINDING_UNRESOLVED", document.path, line_number, match.start() + 1, "required bracket binding remains unresolved: {}".format(raw), ) ) for pattern in PENDING_PATTERNS: for match in pattern.finditer(line): raw = match.group(0).strip() key = (document.path, line_number, match.start() + 1, raw) if key in observed: continue observed.add(key) issues.append( issue( "BINDING_UNRESOLVED", document.path, line_number, match.start() + 1, "unresolved integration binding or placeholder: {}".format(raw), ) ) def retirement_context(line: str, start: int, end: int) -> bool: window = line[max(0, start - 100) : min(len(line), end + 100)] return RETIREMENT_CONTEXT_RE.search(window) is not None or NEGATIVE_SEARCH_RE.search(line) is not None def validate_retired_concepts(documents: Sequence[Document], issues: List[Issue]) -> None: for document in documents: for line_number, line in enumerate(document.text.splitlines(), 1): for code, pattern in RETIRED_CONCEPTS: for match in pattern.finditer(line): if retirement_context(line, match.start(), match.end()): continue issues.append( issue( code, document.path, line_number, match.start() + 1, "authoritative use of retired concept: {}".format(match.group(0)), ) ) def document_by_path(documents: Sequence[Document], path: str) -> Optional[Document]: return next((document for document in documents if document.path == path), None) def read_scoped_document(root: Path, path: str, issues: List[Issue]) -> Optional[Document]: absolute = root / path if not absolute.is_file(): issues.append(issue("AUTHORITY_FILE_MISSING", path, 1, 1, "required scoped authority is missing")) return None try: return Document(path, absolute.read_text(encoding="utf-8")) except (OSError, UnicodeError) as error: issues.append( issue( "AUTHORITY_READ_ERROR", path, 1, 1, "cannot read UTF-8 Markdown: {}".format(error), ) ) return None def heading_level_and_text(line: str) -> Optional[Tuple[int, str]]: match = re.match(r"^(#{1,6})\s+(.+?)\s*$", line) return (len(match.group(1)), match.group(2)) if match else None def section_lines( document: Document, heading_pattern: re.Pattern ) -> List[Tuple[int, str]]: lines = document.text.splitlines() for index, line in enumerate(lines): heading = heading_level_and_text(line) if heading is None or not heading_pattern.search(heading[1]): continue level = heading[0] selected: List[Tuple[int, str]] = [] for following_index in range(index + 1, len(lines)): following = lines[following_index] next_heading = heading_level_and_text(following) if next_heading is not None and next_heading[0] <= level: break selected.append((following_index + 1, following)) return selected return [] def count_value(token: str) -> Optional[int]: normalized = token.strip().lower() if normalized.isdigit(): return int(normalized) return COUNT_WORDS.get(normalized) def provisional_selection_pairs(text: str) -> List[Tuple[int, int, int, int]]: pairs: List[Tuple[int, int, int, int]] = [] for line_number, line in enumerate(text.splitlines(), 1): direct_matches = list(PROVISIONAL_SELECTION_RE.finditer(line)) for match in direct_matches: selected = count_value(match.group("selected")) offered = count_value(match.group("offered")) if selected is not None and offered is not None: pairs.append((selected, offered, line_number, match.start() + 1)) if direct_matches or not re.search(r"\bprovisional\b", line, re.IGNORECASE): continue if not re.search(r"\b(?:recruits?|offers?|company)\b", line, re.IGNORECASE): continue selected_matches: List[re.Match] = [] for pattern in PROVISIONAL_SELECTED_COUNT_RES: selected_matches = list(pattern.finditer(line)) if selected_matches: break offered_matches = [] for pattern in PROVISIONAL_OFFERED_COUNT_RES: offered_matches = [ match for match in pattern.finditer(line) if not re.search( r"\b(?:unchosen|selected|remaining|other)\b", match.group(0), re.IGNORECASE, ) ] if offered_matches: break selected_values = { count_value(match.group("count")) for match in selected_matches } - {None} offered_values = { count_value(match.group("count")) for match in offered_matches } - {None} if len(selected_values) == 1 and len(offered_values) == 1: column = selected_matches[0].start() + 1 if selected_matches else 1 pairs.append( (next(iter(selected_values)), next(iter(offered_values)), line_number, column) ) return pairs def selection_is_revisable_before_creation(text: str) -> bool: return REVISABLE_SELECTION_RE.search(text) is not None and BEFORE_RUN_CREATION_RE.search(text) is not None def validate_provisional_selection_presentation( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue] ) -> None: product = document_by_path(documents, PRODUCT_PATH) screen = document_by_path(documents, SCREEN_PATH) if product is None or screen is None: return product_pairs = provisional_selection_pairs(product.text) product_values = {(selected, offered) for selected, offered, _, _ in product_pairs} if len(product_values) != 1: issues.append( issue( "PROVISIONAL_SELECTION_CONTRACT_INVALID", PRODUCT_PATH, product_pairs[0][2] if product_pairs else 1, product_pairs[0][3] if product_pairs else 1, "product authority must declare one consistent provisional selected/offered pair", ) ) return expected = next(iter(product_values)) primary_candidates: List[Tuple[Definition, str]] = [] for definition in definitions: if definition.path != SCREEN_PATH or not PAGE_ID_RE.fullmatch(definition.identifier): continue row_text = " | ".join(definition.cells) if re.search(r"\bprovisional\b", row_text, re.IGNORECASE) and re.search( r"\brecruits?\b", row_text, re.IGNORECASE ): primary_candidates.append((definition, row_text)) primary_valid_ids = { definition.identifier for definition, row_text in primary_candidates if expected in { (selected, offered) for selected, offered, _, _ in provisional_selection_pairs(row_text) } and selection_is_revisable_before_creation(row_text) } primary_valid = bool(primary_valid_ids) onboarding = section_lines(screen, re.compile(r"\bonboarding\b", re.IGNORECASE)) onboarding_by_screen: Dict[str, List[str]] = { identifier: [] for identifier in primary_valid_ids } for _, line in onboarding: for identifier in primary_valid_ids: if re.search(r"`{}`".format(re.escape(identifier)), line): onboarding_by_screen[identifier].append(line) onboarding_valid = False for lines in onboarding_by_screen.values(): grouped_text = "\n".join(lines) if ( expected in { (selected, offered) for selected, offered, _, _ in provisional_selection_pairs(grouped_text) } and selection_is_revisable_before_creation(grouped_text) ): onboarding_valid = True break if primary_valid and onboarding_valid: return missing: List[str] = [] if not primary_valid: missing.append("a stable screen row") if not onboarding_valid: missing.append("the onboarding map") issues.append( issue( "PROVISIONAL_SELECTION_PRESENTATION_MISMATCH", SCREEN_PATH, primary_candidates[0][0].line if primary_candidates else 1, primary_candidates[0][0].column if primary_candidates else 1, "{} must present choosing {} of {} provisional recruits as revisable before run creation".format( " and ".join(missing), expected[0], expected[1] ), ) ) def semantic_plain_text(value: str) -> str: return re.sub(r"\s+", " ", re.sub(r"[`*]", "", value)).strip().lower() def semantic_has_groups(value: str, groups: Sequence[Sequence[str]]) -> bool: plain = semantic_plain_text(value) return all(any(re.search(pattern, plain, re.IGNORECASE) for pattern in group) for group in groups) def find_action_row( document: Document, action_pattern: re.Pattern ) -> Optional[Tuple[Tuple[str, ...], int]]: for line_number, line in enumerate(document.text.splitlines(), 1): cells = split_markdown_row(line) if cells is None or len(cells) < 3: continue if action_pattern.search(normalize_cell(cells[0])): return cells, line_number return None def row_cell_has_groups( row: Optional[Tuple[Tuple[str, ...], int]], cell_index: int, groups: Sequence[Sequence[str]], ) -> bool: return row is not None and len(row[0]) > cell_index and semantic_has_groups( row[0][cell_index], groups ) def seed_precedes_opening_generation(value: str) -> bool: plain = semantic_plain_text(value) return re.search( r"(?:run_seed|run seed|seed_reserved|seed).{0,360}" r"(?:reserv(?:e|ed|ation)|durab(?:le|ly)|persist(?:ed|ence)?|" r"flush(?:ed)?|read[ -]?back).{0,360}" r"\b(?:before|prior to)\b.{0,160}" r"(?:opening (?:offer )?generat(?:or|ion)|opening generator|" r"generat(?:e|ed|ing|ion) (?:the )?(?:four )?opening)", plain, ) is not None def validate_run_creation_transaction( documents: Sequence[Document], issues: List[Issue] ) -> None: save = document_by_path(documents, SAVE_PATH) recruit = document_by_path(documents, RECRUIT_PATH) if save is None or recruit is None: return seed_lines = section_lines( save, re.compile( r"(?:\bseed[- ]first\b.*\brun\s+creation\b|" r"\brun\s+creation\b.*\bseed(?:[- ]first|\s+(?:order|reservation|boundary))\b)", re.IGNORECASE, ), ) opening_lines = section_lines( recruit, re.compile(r"\bopening\b.*\b(?:offer|generation)\b|\b(?:offer|generation)\b.*\bopening\b", re.IGNORECASE), ) seed_text = "\n".join(line for _, line in seed_lines) opening_text = "\n".join(line for _, line in opening_lines) seed_errors: List[str] = [] if not seed_lines: seed_errors.append("seed-first Run-creation section is missing") elif not seed_precedes_opening_generation(seed_text): seed_errors.append("save authority does not place durable seed read-back before opening generation") if not opening_lines or not semantic_has_groups( opening_text, ( (r"\bopening generation\b", r"\bopening generator\b"), (r"\brun_creation_draft\b", r"\brun creation draft\b"), (r"\bread[ -]?back\b",), (r"\bseed[- ]reservation\b", r"\breserved seed\b", r"\bseed_reserved\b"), (r"\blegal only\b", r"\bonly from\b", r"\bcannot\b.*\bbefore\b"), ), ): seed_errors.append("Recruit generation is not bound to a read-back seed-reservation draft") if seed_errors: issues.append( issue( "RUN_CREATION_SEED_ORDER_MISMATCH", SAVE_PATH, seed_lines[0][0] if seed_lines else 1, 1, "seed/order authority is incomplete or contradictory: {}".format( "; ".join(sorted(set(seed_errors))) ), ) ) transaction_errors: List[str] = [] if not semantic_has_groups( save.text, ( (r"\brun_creation_draft\b",), (r"\brun_seed\b",), (r"\bopening_offers\s*\[\s*0\.\.3\s*\]",), (r"\bselected_offer_ids\s*\[\s*2\s*\]",), (r"\bselection_revision\b",), (r"\bmutually exclusive\b",), (r"\bcurrent_run\b",), ), ): transaction_errors.append( "durable draft schema lacks seed, exact-four, exact-pair, revision, or exclusivity state" ) begin_row = find_action_row( save, re.compile( r"^(?:begin|start|initialize|reserve)\b.*\b(?:run\s+creation|creation\s+draft)\b$", re.IGNORECASE, ), ) materialize_row = find_action_row( save, re.compile( r"^(?:materialize|persist|store)\b.*\bopening\b.*\boffers?\b$", re.IGNORECASE, ), ) selection_row = find_action_row( save, re.compile( r"^(?:set|save|persist|revise|accept)\b.*\b(?:opening\s+selection|selected\s+pair)\b$", re.IGNORECASE, ), ) commit_row = find_action_row( save, re.compile( r"^(?:commit|promote|finalize)\b.*\b(?:run\s+creation|creation\s+draft)\b$", re.IGNORECASE, ), ) if not row_cell_has_groups( begin_row, 2, ( (r"\bdraft\b", r"\brun_creation_draft\b"), (r"\bseed_reserved\b", r"\breserved seed\b"), (r"\bno offers\b", r"\bempty offer"), ( r"\bno current_run\b", r"\bno offers or current_run\b", r"\bcurrent_run\b.*\bnull\b", ), ), ): transaction_errors.append("Begin run creation does not commit one seed-only draft") if not ( row_cell_has_groups(materialize_row, 1, ((r"\bdraft\b",), (r"\bseed\b",))) and row_cell_has_groups( materialize_row, 2, ( (r"\ball four\b", r"\bfour\b.*\boffer", r"\b0\.\.3\b"), (r"\boffer(?: records?| ids?)\b",), (r"\boutput hashes?\b",), (r"\boffer[- ]set hash\b",), ), ) ): transaction_errors.append("opening materialization does not bind one draft to all four offers and hashes") if not ( row_cell_has_groups( selection_row, 1, ( (r"\bdraft\b",), (r"\bexactly two\b", r"\btwo distinct\b"), (r"\boffer ids?\b",), ), ) and row_cell_has_groups( selection_row, 2, ( (r"\bsame four\b",), (r"\bpair\b",), (r"\brevision\b",), (r"\breceipt\b",), ), ) ): transaction_errors.append("selection transaction is not an exact, durable, revisable pair over the saved four") if not ( row_cell_has_groups( commit_row, 1, ((r"\bdraft\b",), (r"\bselection revision\b",), (r"\bpair\b",)), ) and row_cell_has_groups( commit_row, 2, ( (r"\bdraft removed\b", r"\bremove(?:s|d)? the draft\b"), (r"\bsame\b.*\bseed\b",), (r"\bselected recruits?\b", r"\bselected recruit records?\b"), (r"\bcurrent_run\b",), (r"\breceipt\b",), ), ) ): transaction_errors.append("Run promotion row does not transfer the same draft seed/pair into current_run") if not semantic_has_groups( opening_text, ( (r"\ball four\b",), (r"\bone materialization commit\b", r"\bone commit\b"), (r"\boffer ids?\b",), (r"\boutput hashes?\b", r"\bfour hashes\b"), ), ): transaction_errors.append("Recruit authority lacks one-commit materialization of the saved four") if not semantic_has_groups( seed_text, ( (r"\brevis(?:e|es|ed|ing)\b", r"\bdifferent legal pair\b"), (r"\bselection_revision\b", r"\bselection revision\b"), (r"\buntil run commit\b", r"\bbefore run commit\b"), (r"\bwithout regenerating\b", r"\bno generator call\b", r"\bnever regenerat"), ), ): transaction_errors.append("accepted selection is not durably revisable without offer regeneration") if not semantic_has_groups( seed_text, ( ( r"\batomic\b", r"\ball[- ]or[- ]nothing\b", r"\bindivisible\b", r"\b(?:one|single)\s+(?:committed|applied)\s+state\b", ), (r"\bremove(?:s|d)? the draft\b", r"\bdraft removed\b"), (r"\bcurrent_run\b",), (r"\bsame\b.{0,80}\brun_seed\b", r"\bsame seed\b"), (r"\bselected\b.{0,40}\brecruit records?\b", r"\btwo unchanged recruit records?\b"), (r"\boriginal receipt\b", r"\bone applied receipt\b"), ( r"\bno second run\b", r"\bcannot\b.{0,40}\b(?:create|start)\b.{0,20}\bsecond run\b", r"\bneither\b.{0,100}\bsecond run\b", r"\bnever recreate\b", r"\btransfer twice\b", ), ), ): transaction_errors.append("promotion is not proven atomic and exactly-once under retry") if transaction_errors: issues.append( issue( "RUN_CREATION_TRANSACTION_INCOMPLETE", SAVE_PATH, seed_lines[0][0] if seed_lines else 1, 1, "durable Run-creation transaction is incomplete: {}".format( "; ".join(sorted(set(transaction_errors))) ), ) ) def semantic_term(value: str) -> str: term = value.lower() if term.endswith("ies") and len(term) > 4: return term[:-3] + "y" if term.endswith("ing") and len(term) > 5: term = term[:-3] if len(term) > 3 and term[-1] == term[-2]: term = term[:-1] return term if term.endswith("ed") and len(term) > 4: return term[:-2] if term.endswith(("ches", "shes", "xes", "zes")) and len(term) > 5: return term[:-2] if term.endswith("s") and not term.endswith("ss") and len(term) > 4: return term[:-1] return term def semantic_terms(value: str) -> Set[str]: terms = {semantic_term(word) for word in re.findall(r"[a-z]+", value.lower())} return {term for term in terms if len(term) >= 3 and term not in SEMANTIC_STOP_WORDS} def region_semantic_terms(value: str) -> Set[str]: generic = { "add", "art", "battle", "boss", "content", "disclose", "encounter", "enemy", "first", "guild", "introduce", "item", "known", "layer", "major", "music", "player", "pressure", "preview", "region", "remain", "reward", "route", "shop", "show", "stem", "use", "visible", } return semantic_terms(value) - generic def region_content_bindings( document: Document, ) -> Tuple[Dict[str, RegionContentBinding], List[str]]: rows: Dict[str, RegionContentBinding] = {} errors: List[str] = [] for line_number, line in section_lines( document, re.compile(r"\bregion\s+matrix\b", re.IGNORECASE) ): cells = split_markdown_row(line) if cells is None or not cells: continue match = re.search(r"\bRG-\d{2}\b", normalize_cell(cells[0]), re.IGNORECASE) if match is None: continue identifier = match.group(0).upper() if identifier in rows: errors.append("{} appears more than once in the content region matrix".format(identifier)) continue if len(cells) < 6: errors.append("{} lacks framing, behavior, grammar, or production columns".format(identifier)) continue bold_name = re.search(r"\*\*([^*]+)\*\*", cells[1]) name = ( bold_name.group(1).strip() if bold_name is not None else normalize_cell(cells[1]).split(";", 1)[0].strip() ) rows[identifier] = RegionContentBinding( identifier, name, cells[1], "{} {}".format(cells[2], cells[3]), cells[5], line_number, ) return rows, errors def region_art_bindings( document: Document, ) -> Tuple[Dict[str, RegionArtBinding], List[str]]: lines = section_lines( document, re.compile(r"\beight\s+region\s+(?:keys|bindings?)\b", re.IGNORECASE) ) columns: Dict[str, int] = {} rows: Dict[str, RegionArtBinding] = {} errors: List[str] = [] for line_number, line in lines: cells = split_markdown_row(line) if cells is None: continue normalized = tuple(normalize_cell(cell) for cell in cells) if not columns and any("art id" in cell for cell in normalized): for index, cell in enumerate(normalized): if "art id" in cell: columns["identity"] = index elif "content binding" in cell: columns["binding"] = index elif "biome" in cell and "landmark" in cell: columns["biome"] = index elif "palette" in cell and ("light" in cell or "material" in cell): columns["palette"] = index elif "hazard" in cell or "mechanic readability" in cell: columns["hazard"] = index continue identity_index = columns.get("identity", 0) if identity_index >= len(cells): continue match = re.search( r"\bART-REG-\d{2}\b", normalize_cell(cells[identity_index]), re.IGNORECASE ) if match is None: continue identifier = match.group(0).upper() if identifier in rows: errors.append("{} appears more than once in the region-art table".format(identifier)) continue required = {"identity", "binding", "biome", "palette", "hazard"} if not required <= set(columns) or max(columns.values()) >= len(cells): errors.append( "region-art table lacks art ID, exact content binding, biome/landmark, palette, or hazard columns" ) continue targets = tuple( match.group(0).upper() for match in re.finditer(r"\bRG-\d{2}\b", cells[columns["binding"]], re.IGNORECASE) ) rows[identifier] = RegionArtBinding( identifier, targets, cells[columns["biome"]], cells[columns["palette"]], cells[columns["hazard"]], line_number, ) return rows, errors def distinctive_region_terms( rows: Dict[str, RegionContentBinding], identifier: str, field: str ) -> Set[str]: source = region_semantic_terms(getattr(rows[identifier], field)) other_terms: Set[str] = set() for other_identifier, row in rows.items(): if other_identifier != identifier: other_terms.update(region_semantic_terms(getattr(row, field))) return source - other_terms def validate_region_art_bindings( documents: Sequence[Document], issues: List[Issue] ) -> None: content_document = document_by_path(documents, REGION_PATH) art_document = document_by_path(documents, ART_PATH) if content_document is None or art_document is None: return content_rows, content_errors = region_content_bindings(content_document) art_rows, art_errors = region_art_bindings(art_document) errors = list(content_errors) + list(art_errors) missing_content = sorted(set(REGION_IDS) - set(content_rows)) missing_art = sorted(set(REGION_ART_IDS) - set(art_rows)) if missing_content: errors.append("content is missing {}".format(", ".join(missing_content))) if missing_art: errors.append("art is missing {}".format(", ".join(missing_art))) target_owners: Dict[str, List[str]] = {} for art_id, row in art_rows.items(): for target in row.targets: target_owners.setdefault(target, []).append(art_id) for region_id in REGION_IDS: owners = target_owners.get(region_id, []) if not owners: errors.append("{} has no art target".format(region_id)) elif len(owners) != 1: errors.append("{} is targeted by {}".format(region_id, ", ".join(sorted(owners)))) channel_rules = (("biome", 4), ("palette", 2), ("hazard", 3)) for art_id, region_id in zip(REGION_ART_IDS, REGION_IDS): art_row = art_rows.get(art_id) content_row = content_rows.get(region_id) if art_row is None or content_row is None: continue if len(art_row.targets) != 1: errors.append( "{} must declare exactly one content target, found {}".format( art_id, len(art_row.targets) ) ) elif art_row.targets[0] != region_id: errors.append( "{} targets {} instead of same-ordinal {}".format( art_id, art_row.targets[0], region_id ) ) normalized_biome = normalize_cell(art_row.biome) if normalize_cell(content_row.name) not in normalized_biome: errors.append( "{} biome/landmark brief does not name {}".format(art_id, content_row.name) ) for field, minimum in channel_rules: expected_terms = distinctive_region_terms(content_rows, region_id, field) art_terms = region_semantic_terms(getattr(art_row, field)) overlap = expected_terms & art_terms required = min(minimum, len(expected_terms)) if required == 0 or len(overlap) < required: errors.append( "{} {} brief has {} distinctive {} anchors; expected at least {}".format( art_id, field, len(overlap), region_id, required ) ) continue conflicting = [] for other_region in REGION_IDS: if other_region == region_id or other_region not in content_rows: continue score = len( distinctive_region_terms(content_rows, other_region, field) & art_terms ) if score >= len(overlap): conflicting.append("{}={}".format(other_region, score)) if conflicting: errors.append( "{} {} brief conflicts with {} ({} anchors)".format( art_id, field, ", ".join(conflicting), len(overlap) ) ) if errors: issues.append( issue( "REGION_ART_BINDING_MISMATCH", ART_PATH, min((row.line for row in art_rows.values()), default=1), 1, "region content/art bindings are incomplete or incompatible: {}".format( "; ".join(sorted(set(errors))) ), ) ) def hidden_content_bindings( document: Document, ) -> Tuple[Dict[str, HiddenProfessionContent], List[str]]: lines = section_lines( document, re.compile(r"\bhidden\b.*\b(?:hybrid\s+)?professions\b", re.IGNORECASE), ) production_column: Optional[int] = None mechanic_columns: Tuple[int, ...] = () rows: Dict[str, HiddenProfessionContent] = {} errors: List[str] = [] for line_number, line in lines: cells = split_markdown_row(line) if cells is None: continue normalized = tuple(normalize_cell(cell) for cell in cells) if production_column is None and any(cell == "id" for cell in normalized): production_column = next( (index for index, cell in enumerate(normalized) if "production" in cell), None ) mechanic_columns = tuple( index for index, cell in enumerate(normalized) if "core rule" in cell or "target/reach" in cell ) continue match = re.search(r"\b(PF-H\d{2})\b", normalize_cell(cells[0]), re.IGNORECASE) if match is None: continue identifier = match.group(1).upper() if identifier in rows: errors.append("{} is defined more than once in the hidden content table".format(identifier)) continue if production_column is None or production_column >= len(cells) or len(cells) < 2: errors.append("hidden content table lacks identity, mechanic, or production columns") continue identity_parts = [part.strip() for part in normalize_cell(cells[1]).split(";")] parents = tuple(sorted(set(re.findall(r"\bPF-B\d{2}\b", cells[1])))) production = cells[production_column] brief = production.split(":", 1)[1] if ":" in production else production segments = tuple(part.strip() for part in brief.split(",") if part.strip()) mechanic = " ".join( cells[index] for index in mechanic_columns if index < len(cells) ) rows[identifier] = HiddenProfessionContent( identifier, identity_parts[0] if identity_parts else "", parents, mechanic, segments, line_number, ) return rows, errors def hidden_art_bindings( document: Document, ) -> Tuple[Dict[str, HiddenProfessionArt], List[str]]: lines = section_lines( document, re.compile( r"\bhidden\b.*\bprofession\b.*\b(?:kits?|bindings?|art)\b", re.IGNORECASE, ), ) columns: Dict[str, int] = {} rows: Dict[str, HiddenProfessionArt] = {} errors: List[str] = [] for line_number, line in lines: cells = split_markdown_row(line) if cells is None: continue normalized = tuple(normalize_cell(cell) for cell in cells) if not columns and any("art id" in cell for cell in normalized): for index, cell in enumerate(normalized): if "art id" in cell: columns["identity"] = index elif "parent" in cell: columns["parents"] = index elif "silhouette" in cell or "translation" in cell: columns["translation"] = index elif "prop" in cell or "anchor" in cell or "equipment" in cell: columns["props"] = index elif "vfx" in cell or "event cue" in cell: columns["vfx"] = index elif "animation" in cell or "action verb" in cell or "motion" in cell: columns["actions"] = index continue identity_index = columns.get("identity", 0) if identity_index >= len(cells): continue match = re.search( r"\bPF-H\d{2}\b", normalize_cell(cells[identity_index]), re.IGNORECASE ) if match is None: continue identifier = match.group(0).upper() if identifier in rows: errors.append("{} appears more than once in the hidden-art table".format(identifier)) continue required = {"identity", "parents", "translation", "props", "vfx", "actions"} if not required <= set(columns) or max(columns.values()) >= len(cells): errors.append("hidden-art table lacks identity, parent, silhouette, prop, VFX, or action columns") continue rows[identifier] = HiddenProfessionArt( identifier, cells[columns["identity"]], tuple(sorted(set(re.findall(r"\bPF-B\d{2}\b", cells[columns["parents"]])))), cells[columns["translation"]], cells[columns["props"]], cells[columns["vfx"]], cells[columns["actions"]], line_number, ) return rows, errors def validate_hidden_profession_art( documents: Sequence[Document], issues: List[Issue] ) -> None: content_document = document_by_path(documents, PROFESSION_PATH) art_document = document_by_path(documents, ART_PATH) if content_document is None or art_document is None: return content_rows, content_errors = hidden_content_bindings(content_document) art_rows, art_errors = hidden_art_bindings(art_document) mismatch_errors = list(content_errors) + list(art_errors) missing_content = sorted(set(HIDDEN_PROFESSION_IDS) - set(content_rows)) missing_art = sorted(set(HIDDEN_PROFESSION_IDS) - set(art_rows)) if missing_content: mismatch_errors.append("content is missing {}".format(", ".join(missing_content))) if missing_art: mismatch_errors.append("art is missing {}".format(", ".join(missing_art))) for identifier in HIDDEN_PROFESSION_IDS: content = content_rows.get(identifier) art = art_rows.get(identifier) if content is None or art is None: continue art_source_lines = art_document.text.splitlines() row_text = ( art_source_lines[art.line - 1] if 0 < art.line <= len(art_source_lines) else " | ".join( (art.identity, " ".join(art.parents), art.translation, art.props, art.vfx, art.actions) ) ) if HIDDEN_ART_UNRESOLVED_RE.search(row_text) or has_pending_marker(row_text): issues.append( issue( "HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED", ART_PATH, art.line, 1, "{} retains unresolved identity, parent, mechanic, or production binding language".format( identifier ), ) ) continue ordinal = int(identifier[-2:]) expected_art_id = "ART-PRO-H{:02d}".format(ordinal) expected_content_id = "CONTENT.PROFESSION.HIDDEN[{:02d}]".format(ordinal) identity_plain = semantic_plain_text(art.identity) if ( expected_art_id.lower() not in identity_plain or expected_content_id.lower() not in identity_plain or identifier.lower() not in identity_plain or content.name.lower() not in identity_plain ): mismatch_errors.append("{} does not bind its exact art/content identity and name".format(identifier)) if len(content.parents) != 2 or art.parents != content.parents: mismatch_errors.append( "{} art parents {} do not equal content parents {}".format( identifier, "+".join(art.parents) if art.parents else "none", "+".join(content.parents) if content.parents else "none", ) ) fields = { "silhouette translation": art.translation, "props/anchors": art.props, "VFX/event cue": art.vfx, "animation/action verbs": art.actions, } for label, value in fields.items(): if not semantic_terms(value): mismatch_errors.append("{} has no resolved {} semantics".format(identifier, label)) mechanic_overlap = semantic_terms(content.mechanic) & semantic_terms(art.translation) if len(mechanic_overlap) < 2: mismatch_errors.append( "{} silhouette translation does not express its content mechanic".format(identifier) ) if len(content.production_segments) < 3: mismatch_errors.append( "{} content production brief has fewer than three semantic channels".format(identifier) ) continue production_targets = ( ("silhouette", art.translation), ("props", art.props), ("action", art.actions), ("VFX", art.vfx), ) visual_terms = semantic_terms(content.production_segments[0]) for label, value in production_targets[:2]: if not visual_terms or not (visual_terms & semantic_terms(value)): mismatch_errors.append("{} {} does not bind the content visual brief".format(identifier, label)) action_terms = semantic_terms(content.production_segments[1]) if not action_terms or not (action_terms & semantic_terms(art.actions)): mismatch_errors.append("{} actions do not bind the content motion brief".format(identifier)) vfx_terms = semantic_terms(content.production_segments[2]) if not vfx_terms or not (vfx_terms & semantic_terms(art.vfx)): mismatch_errors.append("{} VFX does not bind the content cue brief".format(identifier)) combined_art = " ".join((art.translation, art.vfx, art.actions)) for extra in content.production_segments[3:]: extra_terms = semantic_terms(extra) if not extra_terms or not (extra_terms & semantic_terms(combined_art)): mismatch_errors.append( "{} art does not bind production brief segment '{}'".format( identifier, normalize_cell(extra) ) ) if mismatch_errors: issues.append( issue( "HIDDEN_PROFESSION_ART_MISMATCH", ART_PATH, min((row.line for row in art_rows.values()), default=1), 1, "hidden content/art bindings are incomplete or incompatible: {}".format( "; ".join(sorted(set(mismatch_errors))) ), ) ) def endpoint_kind(value: str) -> Optional[str]: exposed = re.search(r"\b(?:exposed|front)\b", value, re.IGNORECASE) is not None protected = re.search(r"\b(?:protected|rear|last)\b", value, re.IGNORECASE) is not None if exposed == protected: return None return "exposed" if exposed else "protected" PARTY_ENDPOINT_EXPRESSION = ( r"(?:first\s+exposed|exposed(?:\s+(?:position\s+0\s+)?front)?|" r"protected(?:\s+rear)?|rear|last|front)" ) PARTY_SIDE_EXPRESSION = r"(?:screen[- ]?)?(?:left|right)" def extract_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]: direction_re = re.compile( r"\bleft\s*-?to\s*-?right\b\s+from\s+(.{1,50}?)\s+to\s+(.{1,50}?)(?:[.;]|$)", re.IGNORECASE, ) concept_then_side_re = re.compile( r"\b(?P{})\b[^.;|]{{0,40}}?\b(?P{})\b".format( PARTY_ENDPOINT_EXPRESSION, PARTY_SIDE_EXPRESSION ), re.IGNORECASE, ) side_then_concept_re = re.compile( r"\b(?P{})\b\s*(?:is|=|:)\s*(?:the\s+)?" r"(?P{})\b".format( PARTY_SIDE_EXPRESSION, PARTY_ENDPOINT_EXPRESSION ), re.IGNORECASE, ) lines = document.text.splitlines() for index in range(len(lines)): window_lines: List[str] = [] for candidate in lines[index : index + 3]: if not candidate.strip() and window_lines: break window_lines.append(candidate.strip()) window = re.sub(r"[`*]", "", " ".join(window_lines)) match = direction_re.search(window) if match: left = endpoint_kind(match.group(1)) right = endpoint_kind(match.group(2)) if left is not None and right is not None and left != right: return left, right, index + 1, match.start() + 1 assignments: Dict[str, Set[str]] = {"left": set(), "right": set()} for pattern in (concept_then_side_re, side_then_concept_re): for endpoint_match in pattern.finditer(window): concept = endpoint_kind(endpoint_match.group("concept")) side_match = re.search( r"\b(left|right)\b", endpoint_match.group("side"), re.IGNORECASE ) if concept is not None and side_match is not None: assignments[side_match.group(1).lower()].add(concept) if ( len(assignments["left"]) == 1 and len(assignments["right"]) == 1 and assignments["left"] != assignments["right"] ): return ( next(iter(assignments["left"])), next(iter(assignments["right"])), index + 1, 1, ) return None def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]: return extract_party_direction(document) def extract_art_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]: return extract_party_direction(document) def validate_party_direction(documents: Sequence[Document], issues: List[Issue]) -> None: ui = document_by_path(documents, UI_PATH) art = document_by_path(documents, ART_PATH) if ui is None or art is None: return ui_direction = extract_ui_party_direction(ui) art_direction = extract_art_party_direction(art) if ui_direction is not None and art_direction is not None and ui_direction[:2] == art_direction[:2]: return line = art_direction[2] if art_direction else 1 column = art_direction[3] if art_direction else 1 rendered_ui = "{} left / {} right".format(*ui_direction[:2]) if ui_direction else "not explicit" rendered_art = "{} left / {} right".format(*art_direction[:2]) if art_direction else "not explicit" issues.append( issue( "PARTY_DIRECTION_MISMATCH", ART_PATH, line, column, "party endpoints must agree with UI authority (UI: {}; art: {})".format(rendered_ui, rendered_art), ) ) def normalize_cell(value: str) -> str: return re.sub(r"\s+", " ", re.sub(r"[`*]", "", value)).strip().lower() def first_fenced_block(lines: Sequence[Tuple[int, str]]) -> str: collected: List[str] = [] in_fence = False for _, line in lines: if line.strip().startswith("```"): if in_fence: return "\n".join(collected) in_fence = True continue if in_fence: collected.append(line) return "" def recruit_content_part_counts(document: Document) -> Tuple[Dict[str, int], List[str]]: errors: List[str] = [] record_lines = section_lines(document, re.compile(r"\brecruit\s+record\b", re.IGNORECASE)) record_block = first_fenced_block(record_lines) record_fields = set(re.findall(r"\b[a-z][a-z0-9_]*(?:\[\])?\b", record_block)) grammar_lines = section_lines(document, re.compile(r"\bmodular\s+visual\s+grammar\b", re.IGNORECASE)) counts: Dict[str, int] = {} for _, line in grammar_lines: cells = split_markdown_row(line) if cells is None or len(cells) < 2: continue label = normalize_cell(cells[0]) for field, content_pattern, _ in RECRUIT_PART_FIELDS: if not content_pattern.fullmatch(label): continue value = count_value(normalize_cell(cells[1])) if value is None: errors.append("{} lacks an exact content option count".format(field)) elif field in counts: errors.append("{} is declared more than once in the content grammar".format(field)) else: counts[field] = value for field, _, _ in RECRUIT_PART_FIELDS: if field not in record_fields: errors.append("{} is absent from the Recruit Record".format(field)) if field not in counts: errors.append("{} is absent from Modular Visual Grammar".format(field)) return counts, errors def art_declared_source_counts(document: Document) -> List[Tuple[str, int]]: declarations: List[Tuple[str, int]] = [] for line in document.text.splitlines(): cells = split_markdown_row(line) if cells is None or len(cells) < 3: continue label = normalize_cell(cells[0]) if not label or label in ("library", "family") or set(label) <= {"-", ":"}: continue candidate_cells = (cells[1], cells[2]) for candidate in candidate_cells: cleaned = normalize_cell(candidate) match = re.fullmatch(r"(\d+)(?:\s+(?:masters?|identities?))?", cleaned) if match: declarations.append((label, int(match.group(1)))) break return declarations def find_crosswalk_rows( document: Document, ) -> Tuple[Dict[str, Tuple[Tuple[str, ...], int, Dict[str, int]]], List[str]]: lines = section_lines( document, re.compile(r"(?:\brecruit\b.*\bcrosswalk\b|\bcrosswalk\b.*\brecruit\b)", re.IGNORECASE), ) if not lines: return {}, ["explicit recruit content/art crosswalk section is missing"] header: Optional[Tuple[str, ...]] = None columns: Dict[str, int] = {} rows: Dict[str, Tuple[Tuple[str, ...], int, Dict[str, int]]] = {} for line_number, line in lines: cells = split_markdown_row(line) if cells is None: continue normalized = tuple(normalize_cell(cell) for cell in cells) if header is None: candidate_columns: Dict[str, int] = {} for index, cell in enumerate(normalized): if "field" in cell and ( "content" in cell or "recruit" in cell or "record" in cell or cell == "field" ): candidate_columns["field"] = index elif "stored" in cell and "authority" in cell: candidate_columns["content_count"] = index elif "content" in cell and re.search( r"\b(?:count|options?|values?)\b", cell ): candidate_columns["content_count"] = index elif "art" in cell and re.search(r"\b(?:source|target|library|binding)\b", cell): candidate_columns["art_source"] = index elif "art" in cell and re.search(r"\b(?:count|options?|masters?|values?)\b", cell): candidate_columns["art_count"] = index elif re.search(r"\b(?:mapping|resolution|rule|derived\s+assembly)\b", cell): candidate_columns["mapping"] = index if {"field", "content_count", "art_source"} <= set(candidate_columns): header = cells columns = candidate_columns continue if all(re.fullmatch(r":?-{3,}:?", cell.replace(" ", "")) for cell in normalized): continue maximum = max(columns.values()) if len(cells) <= maximum: continue field = normalize_cell(cells[columns["field"]]).replace("[]", "") if not re.fullmatch(r"[a-z][a-z0-9_]*", field): continue if field in rows: return rows, ["{} appears more than once in the art crosswalk".format(field)] rows[field] = (cells, line_number, dict(columns)) if header is None: return {}, [ "crosswalk table requires recruit field, content authority/count, and art binding columns" ] return rows, [] def mapping_is_deterministic(value: str) -> bool: return re.search( r"\b(?:deterministic|one-to-one|same\s+(?:stable\s+)?(?:index|ordinal)|" r"matching\s+(?:index|ordinal)|direct(?:ly)?\s+(?:maps?|indexes?)|" r"nonvisual|audio[- ]owned)\b|1\s*:\s*1", value, re.IGNORECASE, ) is not None def embedded_count(value: str) -> Optional[int]: counts = {int(match.group(0)) for match in re.finditer(r"(? Optional[int]: identifiers: Set[str] = set() for match in CODE_SPAN_RE.finditer(value): expression = match.group(1).strip() expanded = expand_compact_id_expression(expression) if expanded is None or not all(identifier.startswith("ART-") for identifier in expanded): continue identifiers.update(expanded) return len(identifiers) if identifiers else None def validate_recruit_art_crosswalk(documents: Sequence[Document], issues: List[Issue]) -> None: content = document_by_path(documents, RECRUIT_PATH) art = document_by_path(documents, ART_PATH) if content is None or art is None: return content_counts, errors = recruit_content_part_counts(content) rows, row_errors = find_crosswalk_rows(art) errors.extend(row_errors) source_counts = art_declared_source_counts(art) first_crosswalk_line = min((line for _, line, _ in rows.values()), default=1) for field, _, source_pattern in RECRUIT_PART_FIELDS: expected = content_counts.get(field) row = rows.get(field) if expected is None or row is None: if row is None: errors.append("{} lacks an art crosswalk row".format(field)) continue cells, _, columns = row content_authority = normalize_cell(cells[columns["content_count"]]) content_count = embedded_count(content_authority) source = normalize_cell(cells[columns["art_source"]]) explicit_art_count = ( embedded_count(normalize_cell(cells[columns["art_count"]])) if "art_count" in columns else None ) binding_count = bounded_art_binding_count(cells[columns["art_source"]]) art_count = explicit_art_count if "art_count" in columns else binding_count mapping = ( normalize_cell(cells[columns["mapping"]]) if "mapping" in columns else " | ".join(normalize_cell(cell) for cell in cells) ) binding_pattern = RECRUIT_ART_BINDING_PATTERNS[field] matching_sources = { count for label, count in source_counts if source_pattern.search(label) } if content_count != expected: errors.append( "{} crosswalk content count is {}; expected {}".format(field, content_count, expected) ) if field == "voice_effort_set": if not binding_pattern.search("{} {}".format(content_authority, source)): errors.append("voice_effort_set must be explicitly audio-owned/nonvisual") if art_count not in (None, 0): errors.append("voice_effort_set must not declare visual masters") else: if not binding_pattern.search(source): errors.append("{} maps to an unrecognized art source".format(field)) if art_count is not None and art_count != expected: errors.append("{} art count is {}; expected {}".format(field, art_count, expected)) if matching_sources != {expected}: errors.append( "{} art source declares {}; expected exactly {}".format( field, sorted(matching_sources) if matching_sources else "no count", expected, ) ) if art_count is None and matching_sources != {expected}: errors.append("{} lacks a bounded art count".format(field)) deterministic_mapping = ( field == "voice_effort_set" or mapping_is_deterministic(mapping) or binding_count == expected or ( re.search(r"\bone\b", source, re.IGNORECASE) is not None and matching_sources == {expected} ) ) if not deterministic_mapping: errors.append("{} lacks a deterministic mapping rule".format(field)) if not errors: return issues.append( issue( "RECRUIT_ART_CROSSWALK_MISMATCH", ART_PATH, first_crosswalk_line, 1, "recruit field crosswalk is incomplete or incompatible: {}".format("; ".join(sorted(set(errors)))), ) ) def validate_audio_screen_inventory( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> None: audio = document_by_path(documents, AUDIO_PATH) if audio is None: counts["pages.audio_bound"] = 0 return page_ids = { definition.identifier for definition in definitions if definition.path == SCREEN_PATH and PAGE_ID_RE.fullmatch(definition.identifier) } binding_row: Optional[Tuple[int, Tuple[str, ...]]] = None for line_number, line in enumerate(audio.text.splitlines(), 1): cells = split_markdown_row(line) if cells is None or not cells: continue label = normalize_cell(cells[0]) if "screen" in label and "action" in label and "id" in label: binding_row = (line_number, cells) break audio_ids: Set[str] = set() declared_total: Optional[int] = None if binding_row is not None: line_number, cells = binding_row for cell in cells[1:]: for match in CODE_SPAN_RE.finditer(cell): expression = match.group(1).strip() expanded = expand_compact_id_expression(expression) if expanded is None: continue audio_ids.update(identifier for identifier in expanded if PAGE_ID_RE.fullmatch(identifier)) row_text = " | ".join(cells) total_match = re.search(r"\ball\s+(\d+)\s+(?:final\s+)?screens?\b", row_text, re.IGNORECASE) if total_match is None: total_match = re.search(r"\b(\d+)\s+(?:final\s+)?screens?\b", row_text, re.IGNORECASE) if total_match: declared_total = int(total_match.group(1)) else: line_number = 1 counts["pages.audio_bound"] = len(audio_ids) if ( "OUT-006" in page_ids and audio_ids == page_ids and declared_total == len(page_ids) ): return details: List[str] = [] if "OUT-006" not in page_ids: details.append("screen authority lacks OUT-006") missing = page_ids - audio_ids extra = audio_ids - page_ids if missing: details.append("audio missing [{}]".format(format_id_sample(missing))) if extra: details.append("audio has unexpected [{}]".format(format_id_sample(extra))) if declared_total != len(page_ids): details.append("audio declares {} screens; screen registry has {}".format(declared_total, len(page_ids))) if binding_row is None: details.append("Screen and action IDs binding row is missing") issues.append( issue( "AUDIO_SCREEN_INVENTORY_MISMATCH", AUDIO_PATH, line_number, 1, "audio screen binding must equal the complete screen registry: {}".format("; ".join(details)), ) ) REFERENCE_RELATIONSHIP_SCOPE_RE = re.compile( r"\b(?:profession\s+affinit(?:y|ies)|relationships?\s+hooks?|" r"relationships?\s+(?:system|scene|meter|level|arc|reward|prompt|link)s?|" r"affinity\s+(?:system|meter|level|rank|scene)s?|romance|personal[- ]arc)\b", re.IGNORECASE, ) REFERENCE_FIXED_CAST_RE = re.compile( r"\b(?:fixed[- ]cast|fixed\s+(?:named\s+)?(?:protagonists?|characters?|heroes?))\b", re.IGNORECASE, ) REFERENCE_NAMED_THIRTY_RE = re.compile( r"(?:\b30\b.{0,80}\bnamed\s+recruitable\s+characters?\b|" r"\bnamed\s+recruitable\s+characters?\b.{0,80}\b30\b)", re.IGNORECASE, ) REFERENCE_HISTORY_RE = re.compile( r"\b(?:historical|history|earlier|prior|superseded|observed\s+reference|reference\s+census)\b", re.IGNORECASE, ) REFERENCE_EXPLICIT_REJECTION_RE = re.compile( r"(?:\b(?:no|without|reject(?:s|ed|ing)?|retire(?:s|d|ing)?|" r"exclude(?:s|d|ing)?|forbid(?:s|den|ding)?|do\s+not|must\s+not|" r"not\s+(?:approved|required))\b.{0,100}$|" r"^.{0,100}\b(?:is|are|remain)?\s*(?:rejected|retired|excluded|forbidden|" r"not\s+(?:approved|required))\b)", re.IGNORECASE, ) REFERENCE_POSITIVE_AUTHORITY_RE = re.compile( r"\b(?:approved|require(?:s|d)?|must|shall|direct\s+specification|" r"exact\s+(?:scope|commitment)|commitment|authoritative)\b", re.IGNORECASE, ) def reference_retirement_context(value: str, start: int, end: int) -> bool: window = value[max(0, start - 120) : min(len(value), end + 120)] relative_start = min(start, 120) relative_end = relative_start + (end - start) before = window[:relative_start] after = window[relative_end:] if REFERENCE_EXPLICIT_REJECTION_RE.search(before) or REFERENCE_EXPLICIT_REJECTION_RE.search(after): return True return ( REFERENCE_HISTORY_RE.search(window) is not None and REFERENCE_POSITIVE_AUTHORITY_RE.search(window) is None ) def validate_reference_retired_scope(document: Optional[Document], issues: List[Issue]) -> None: if document is None: return observed: Set[Tuple[int, int, str]] = set() for line_number, line in enumerate(document.text.splitlines(), 1): cells = split_markdown_row(line) fragments = cells if cells is not None else (line,) for fragment in fragments: for pattern in (REFERENCE_RELATIONSHIP_SCOPE_RE, REFERENCE_FIXED_CAST_RE): for match in pattern.finditer(fragment): if reference_retirement_context(fragment, match.start(), match.end()): continue column = max(1, line.find(match.group(0)) + 1) observed.add((line_number, column, match.group(0))) for match in REFERENCE_NAMED_THIRTY_RE.finditer(line): if reference_retirement_context(line, match.start(), match.end()): continue observed.add((line_number, match.start() + 1, match.group(0))) for line_number, column, matched in sorted(observed): issues.append( issue( "REFERENCE_RETIRED_SCOPE", REFERENCE_SCOPE_PATH, line_number, column, "reference authority positively prescribes retired fixed-cast/relationship scope: {}".format( matched ), ) ) def require_text_coverage( text: str, requirements: Sequence[Tuple[str, re.Pattern]], code: str, path: str, issues: List[Issue], ) -> None: for name, pattern in requirements: if pattern.search(text): continue issues.append(issue(code, path, 1, 1, "missing required coverage: {}".format(name))) def validate_screen_coverage( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int] ) -> Set[str]: path = "docs/presentation/SCREEN_AND_STATE_MAP.md" document = document_by_path(documents, path) if document is None: counts["pages"] = 0 return set() page_definitions = [ definition for definition in definitions if definition.path == path and re.fullmatch(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}", definition.identifier) ] page_ids = {definition.identifier for definition in page_definitions} counts["pages"] = len(page_ids) if not page_definitions: issues.append(issue("PAGE_REGISTRY_EMPTY", path, 1, 1, "no stable page rows were found")) return page_ids for definition in page_definitions: if len(definition.cells) < 5 or any(not cell.strip() for cell in definition.cells[:5]): issues.append( issue( "PAGE_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} requires ID, page/state, composition, primary action, and state/exit cells".format( definition.identifier ), ) ) page_text = [" | ".join(definition.cells[1:]).lower() for definition in page_definitions] page_requirements = ( ("shop", re.compile(r"\b(?:shop|market|offer)\b")), ("recruit", re.compile(r"\b(?:recruit|hire)\b")), ("equipment", re.compile(r"\b(?:equipment|equip|loadout)\b")), ("party line and reorder", re.compile(r"\b(?:party\s+line|formation|reorder)\b")), ("battle", re.compile(r"\b(?:battle|combat)\b")), ("result", re.compile(r"\b(?:result|victory|reward|diagnosis)\b")), ("failure", re.compile(r"\b(?:failure|defeat|death|casualty|recovery)\b")), ("save", re.compile(r"\b(?:save|cloud|continue)\b")), ("settings", re.compile(r"\b(?:settings|options|controls)\b")), ( "accessibility", re.compile(r"\b(?:accessibility|assist|screen\s+reader|reduced\s+motion|captions|contrast)\b"), ), ("localization", re.compile(r"\b(?:localization|language|locale|text)\b")), ("endgame", re.compile(r"\b(?:endgame|postgame|ending|mastery|new\s+game)\b")), ) for name, pattern in page_requirements: if any(pattern.search(value) for value in page_text): continue issues.append(issue("PAGE_CAPABILITY_MISSING", path, 1, 1, "no page row covers {}".format(name))) require_text_coverage( document.text, ( ("landscape composition", re.compile(r"\blandscape\b", re.IGNORECASE)), ("empty state", re.compile(r"\bempty\b", re.IGNORECASE)), ("error state", re.compile(r"\b(?:error|failed)\b", re.IGNORECASE)), ("confirmation behavior", re.compile(r"\bconfirm(?:ation|ed|ing)?\b", re.IGNORECASE)), ("input coverage", re.compile(r"\b(?:input|controller|keyboard|touch)\b", re.IGNORECASE)), ("localization coverage", re.compile(r"\b(?:localization|language|locale|pseudolocale)\b", re.IGNORECASE)), ( "accessibility coverage", re.compile( r"\b(?:accessibility|screen\s+reader|reduced\s+motion|high\s+contrast|captions)\b", re.IGNORECASE, ), ), ), "PAGE_STATE_COVERAGE_MISSING", path, issues, ) return page_ids def reference_ids_for_path(references: Sequence[Reference], path: str) -> Set[str]: return { identifier for reference in references if reference.path == path for identifier in reference.identifiers } def validate_art_coverage( documents: Sequence[Document], references: Sequence[Reference], issues: List[Issue], ) -> None: path = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md" document = document_by_path(documents, path) if document is None: return art_ids = reference_ids_for_path(references, path) profession_art_ids = ( set(numbered_ids("ART-PRO-B", 12, 2)) | set(numbered_ids("ART-PRO-A", 24, 2)) | set(numbered_ids("ART-PRO-H", 6, 2)) ) groups = ( ("all 42 profession art kits", profession_art_ids), ("all 36 trait presentation IDs", set(numbered_ids("ART-TRT-", 36, 2))), ("all 320 equipment presentation IDs", set(numbered_ids("ART-EQP-", 320, 3))), ("all 60 artifact presentation IDs", set(numbered_ids("ART-AFT-", 60, 2))), ("all 100 ordinary/elite enemy art IDs", set(numbered_ids("ART-ENM-", 100, 3))), ("all 16 boss art IDs", set(numbered_ids("ART-BOS-", 16, 2))), ("all eight region art IDs", set(numbered_ids("ART-REG-", 8, 2))), ) for name, expected in groups: missing = expected - art_ids if missing: issues.append( issue( "ART_ID_COVERAGE_MISSING", path, 1, 1, "{} lack explicit art traceability: [{}]".format(name, format_id_sample(missing)), ) ) require_text_coverage( document.text, ( ( "modular generated recruits", re.compile(r"\b(?:modular|generated)\s+recruits?\b", re.IGNORECASE), ), ( "equipment readability", re.compile(r"\bequipment\b.{0,100}\b(?:readab|silhouette|icon)", re.IGNORECASE | re.DOTALL), ), ("animation matrix", re.compile(r"\banimation\b", re.IGNORECASE)), ("VFX language", re.compile(r"\b(?:VFX|visual\s+effects?)\b", re.IGNORECASE)), ("landscape composition", re.compile(r"\blandscape\b", re.IGNORECASE)), ), "ART_CAPABILITY_MISSING", path, issues, ) def validate_audio_coverage( documents: Sequence[Document], definitions: Sequence[Definition], issues: List[Issue], counts: Dict[str, int], ) -> None: path = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md" document = document_by_path(documents, path) if document is None: for family in ("music", "ambience", "sfx", "voice"): counts["audio.{}".format(family)] = 0 return audio_definitions = [definition for definition in definitions if definition.path == path] expected_families = { "music": { "MUS-G00-TITLE", "MUS-G01-GUILD", "MUS-G02-SHOP", "MUS-G03-PREP", "MUS-G04-RUN", "MUS-G05-RESULT-WIN", "MUS-G06-RESULT-LOSS", "MUS-G07-META", } | { "MUS-R{:02d}-{}".format(region, purpose) for region in range(1, 9) for purpose in ("ROUTE", "BATTLE", "BOSS") }, "ambience": set(numbered_ids("AMB-G", 12, 2)) | { "AMB-R{:02d}-{:02d}".format(region, configuration) for region in range(1, 9) for configuration in range(1, 9) }, "sfx": set(numbered_ids("SFX-UI-", 24, 3)) | set(numbered_ids("SFX-SHP-", 24, 3)) | set(numbered_ids("SFX-EQP-", 20, 3)) | set(numbered_ids("SFX-ORD-", 20, 3)) | set(numbered_ids("SFX-BAT-", 60, 3)) | set(numbered_ids("SFX-RES-", 28, 3)) | set(numbered_ids("SFX-RUN-", 28, 3)) | set(numbered_ids("SFX-REG-", 24, 3)), "voice": set(numbered_ids("VO-SYS-", 16, 3)) | set(numbered_ids("VO-ANN-", 16, 3)), } family_prefixes = { "music": "MUS-", "ambience": "AMB-", "sfx": "SFX-", "voice": "VO-", } for family, expected_ids in expected_families.items(): family_ids = { definition.identifier for definition in audio_definitions if definition.identifier.startswith(family_prefixes[family]) } counts["audio.{}".format(family)] = len(family_ids) if family_ids != expected_ids: missing = expected_ids - family_ids extra = family_ids - expected_ids detail: List[str] = [] if missing: detail.append("missing [{}]".format(format_id_sample(missing))) if extra: detail.append("unexpected [{}]".format(format_id_sample(extra))) issues.append( issue( "AUDIO_REGISTRY_EXACT_SET", path, 1, 1, "{} registry requires exactly {} IDs; found {}{}".format( family, len(expected_ids), len(family_ids), "; " + "; ".join(detail) if detail else "", ), ) ) for definition in audio_definitions: if len(definition.cells) < 2 or any(not cell.strip() for cell in definition.cells[:2]): issues.append( issue( "AUDIO_REGISTRY_ROW_SCHEMA", definition.path, definition.line, definition.column, "{} lacks a production role or configuration".format(definition.identifier), ) ) coverage_text = re.sub(r"[_-]+", " ", document.text) require_text_coverage( coverage_text, ( ("shop offers", re.compile(r"\b(?:shop|offer|market)\b", re.IGNORECASE)), ("recruitment", re.compile(r"\b(?:recruit|hire)\b", re.IGNORECASE)), ("equipment", re.compile(r"\b(?:equipment|equip|loadout)\b", re.IGNORECASE)), ("party reorder", re.compile(r"\b(?:reorder|party\s+line|formation)\b", re.IGNORECASE)), ("battle", re.compile(r"\b(?:battle|combat)\b", re.IGNORECASE)), ("pause", re.compile(r"\bpause\b", re.IGNORECASE)), ("speed", re.compile(r"\bspeed\b", re.IGNORECASE)), ("inspection", re.compile(r"\binspect(?:ion)?\b", re.IGNORECASE)), ("retreat", re.compile(r"\bretreat\b", re.IGNORECASE)), ("permanent casualty", re.compile(r"\b(?:death|casualty|permanent\s+loss)\b", re.IGNORECASE)), ("dismissal", re.compile(r"\bdismiss(?:al|ed)?\b", re.IGNORECASE)), ("reward", re.compile(r"\breward\b", re.IGNORECASE)), ("failure", re.compile(r"\b(?:failure|defeat)\b", re.IGNORECASE)), ( "audio accessibility", re.compile(r"\b(?:accessibility|caption|visual\s+cue|haptic|mono)\b", re.IGNORECASE), ), ( "generated-recruit voice scope", re.compile(r"\b(?:generated|modular)\s+recruits?\b", re.IGNORECASE), ), ), "AUDIO_CAPABILITY_MISSING", path, issues, ) def audit(root: Path) -> AuditResult: root = root.resolve() documents, issues = collect_documents(root) reference_scope = read_scoped_document(root, REFERENCE_SCOPE_PATH, issues) definitions, definition_issues = collect_definitions(documents) issues.extend(definition_issues) counts: Dict[str, int] = {} for rule in REGISTRY_RULES: validate_registry_rule(rule, definitions, issues, counts) validate_equipment_registry(definitions, issues, counts) validate_enemy_registry(definitions, issues, counts) references, reference_issues = collect_references(documents) issues.extend(reference_issues) validate_cross_references(definitions, references, issues) validate_declared_bindings(documents, issues, counts) validate_pending_bindings(documents, issues) validate_retired_concepts(documents, issues) validate_provisional_selection_presentation(documents, definitions, issues) validate_run_creation_transaction(documents, issues) validate_party_direction(documents, issues) validate_recruit_art_crosswalk(documents, issues) validate_hidden_profession_art(documents, issues) validate_region_art_bindings(documents, issues) validate_screen_coverage(documents, definitions, issues, counts) validate_art_coverage(documents, references, issues) validate_audio_coverage( documents, definitions, issues, counts, ) validate_audio_screen_inventory(documents, definitions, issues, counts) validate_reference_retired_scope(reference_scope, issues) unique_issues = {item.sort_key(): item for item in issues} return AuditResult(sorted(unique_issues.values(), key=Issue.sort_key), dict(sorted(counts.items()))) def write_fixture(root: Path) -> None: for directory in AUTHORITY_DIRECTORIES: (root / directory).mkdir(parents=True, exist_ok=True) product_texts = { "GAME_PRODUCT_CONTRACT.md": ( "# Product\n\nGenerated recruits form an ordered horizontal party line.\n" "Choose two of four provisional generated Recruits at no cost.\n" ), "SYSTEMS_AND_BATTLE.md": ( "# Systems\n\nAutomatic battle supports pause, speed, inspect, and retreat.\n" "No relationship system, six-unit cap, three-lane grid, Directive, Anchor, or Cache remains.\n" ), "ECONOMY_AND_BALANCE.md": "# Economy\n\nRecruitment, dismissal, rewards, and replacement have explicit costs.\n", "SAVE_AND_FAILURE_CONTRACT.md": """# Save Death, failure, rollback, and recovery are explicit. ## 2. Save Topology And Envelope ```text run_creation_draft draft_id, run_id, status, run_seed opening_offers[0..3] or empty, opening_offer_set_hash selected_offer_ids[2] or empty, selection_revision current_run or null ``` The run_creation_draft and current_run are mutually exclusive. ## 6. Transaction Boundaries | Action | Pending intent contains | Applied child contains | |---|---|---| | Begin run creation | Contract and derived seed | one seed_reserved run_creation_draft; no offers or current_run | | Materialize opening offers | draft ID and run seed hash | all four offer records and IDs, output hashes, and one offer-set hash | | Set opening selection | draft ID, expected revision, and exactly two distinct offer IDs | same four offers, canonical pair, selection revision, and receipt | | Commit run creation | draft ID, offer-set hash, selection revision, and exact pair | draft removed; same run seed and selected Recruit records in current_run; run-start receipt | ## 7. Run Creation ### 7.1 Seed-First Run Creation The applied Begin operation durably creates a seed_reserved run_creation_draft, then it is flushed and read back before the opening generator may run. The player may revise a different legal pair and increment selection_revision until Run commit without regenerating any opening offer. Commit is one atomic state: it removes the draft, creates current_run with the same run_seed, transfers the two unchanged Recruit records, and stores the original receipt. Duplicate or recovery retries return that original receipt and cannot create a second Run. """, } for name, text in product_texts.items(): (root / "docs/product" / name).write_text(text, encoding="utf-8") def table(ids: Iterable[str], columns_after_id: int, value: str = "decision-bearing") -> str: header = ["ID"] + ["Field {}".format(index) for index in range(1, columns_after_id + 1)] lines = ["| " + " | ".join(header) + " |", "|" + "---|" * len(header)] for identifier in ids: cells = ["`{}`".format(identifier)] + [value] * columns_after_id lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines) + "\n" profession = ["# Profession Catalog", "", "## Base Professions", ""] profession.append(table(numbered_ids("PF-B", 12, 2), 6)) profession.extend(("", "## Regular Advanced Professions", "")) profession.append(table(numbered_ids("PF-A", 24, 2), 5)) profession.extend( ( "", "## Hidden Hybrid Professions", "", "| ID | Name; required bases; deed | Role/band; core rules | Target/reach and automatic cadence | Counters; synergies | Production needs |", "|---|---|---|---|---|---|", "| `PF-H01` | Wardmender; `PF-B01` + `PF-B07`; restore intercepted harm | guard-restore; intercepted harm becomes repair energy for the protected ally. | reach 1; brace, then mend the oldest harm. | Anti-heal delays repair. | `P/O/A/V/S`: mantle buckler, brace/mend, square pulse, repair chime. |", "| `PF-H02` | Tempo Cutter; `PF-B02` + `PF-B11`; alter a timed strike | tempo-pressure; cadence Edge shortens an enemy telegraph between ally actions. | reach 2; step between beats, then cut. | Silence breaks cadence. | `P/O/A/V/S`: bell blades, step/cut, split metronome, cadence strike. |", "| `PF-H03` | Farline Lancer; `PF-B03` + `PF-B05`; pierce a rear target | projectile-reach; a planted bow-spear guides a piercing rear shot. | line reach; plant, draw, then recover. | Movement breaks the guide. | `P/O/A/V/S`: bow-spear, plant/draw/recover, horizon line, guided shot. |", "| `PF-H04` | Route Smith; `PF-B04` + `PF-B09`; change a device by position | reorder-construct; a carried automaton changes guard, strike, and pull modes by indexed position. | adjacent reach; carry, drop, and switch. | Roots stop modes. | `P/O/A/V/S`: pack automaton, carry/drop/switch, numbered footprints, mode click. |", "| `PF-H05` | Eclipse Scribe; `PF-B06` + `PF-B10`; retain an interrupted cast | area-impair; an interrupted charge becomes shadow text released on hindrance expiry. | line spell; collapse, inscribe, release. | Cleanse erases text. | `P/O/A/V/S`: split tablet, collapse/inscribe/release, eclipsed diamond, reversed charge. |", "| `PF-H06` | Loan Factor; `PF-B08` + `PF-B12`; share distinct resources | enable-supply; linked loans repay as flexible Credit after recipient actions. | reach 2; loan, mark, then close. | Isolation creates bad debt. | `P/O/A/V/S`: ledger-knot frame, loan/mark/close, linked tabs, stamped pluck. |", ) ) profession.extend( ( "", "## Topology And No-Filler Audit", "", "| Base | Choices | Hidden participation | Decision split |", "|---|---|---|---|", ) ) for index in range(1, 13): profession.append( "| `PF-B{:02d}` | `PF-A{:02d}`, `PF-A{:02d}` | `PF-H{:02d}` | distinct choice |".format( index, index * 2 - 1, index * 2, (index - 1) % 6 + 1 ) ) (root / "docs/content/PROFESSION_CATALOG.md").write_text( "\n".join(profession) + "\n", encoding="utf-8" ) (root / "docs/content/TRAIT_CATALOG.md").write_text( "# Recruit Trait Catalog\n\n" + table(numbered_ids("TR-", 36, 3), 5), encoding="utf-8" ) equipment_rows = table(numbered_ids("EQ-", 320, 3), 5) equipment_rows = equipment_rows.replace( "| `EQ-284` | decision-bearing", "| `EQ-284` | complete three-band cadence alternation", ) (root / "docs/content/EQUIPMENT_CATALOG.md").write_text( "# Equipment Catalog\n\n" + equipment_rows, encoding="utf-8" ) (root / "docs/content/ARTIFACT_CATALOG.md").write_text( "# Run Artifact Catalog\n\n" + table(numbered_ids("AR-", 60, 3), 3), encoding="utf-8" ) enemy_ids = tuple( "EN-R{}-{:02d}".format(region, ordinal) for region in range(1, 9) for ordinal in range(1, 13 if region <= 4 else 14) ) enemy_lines = ["# Enemy And Boss Catalog", "", table(enemy_ids, 4, "ordinary; decision-bearing")] boss_ids = tuple( "BO-R{}-{:02d}".format(region, ordinal) for region in range(1, 9) for ordinal in range(1, 3) ) enemy_lines.extend(("", "## Bosses - 16 Identities", "", table(boss_ids, 4))) enemy_lines.extend( ( "", "## Count And Assignment Check", "", "| Region | Enemy IDs | Count | Boss IDs | Count |", "|---|---|---:|---|---:|", ) ) for region in range(1, 9): maximum = 12 if region <= 4 else 13 enemy_lines.append( "| `RG-{:02d}` | `EN-R{}-01..EN-R{}-{:02d}` | {} | `BO-R{}-01..BO-R{}-02` | 2 |".format( region, region, region, maximum, maximum, region, region ) ) (root / "docs/content/ENEMY_CATALOG.md").write_text( "\n".join(enemy_lines) + "\n", encoding="utf-8" ) region_rows = ( ("RG-01", "Windglass Verge", "open grass terraces and suspended mineral panes", "front reach and projectile screens", "glass-wind push telegraphs one cadence ahead", "bright terrace layers and wind direction shapes"), ("RG-02", "Siltwake Fens", "reed channels, luminous sediment, and movable banks", "pulls and hindrance expiry", "tide markers shift the safe line index", "layered reeds, waterline, and non-color tide notches"), ("RG-03", "Emberloom Works", "communal kilns and ceramic conduits", "constructs and delayed bursts", "one pressure valve changes a known countdown", "kiln layers, pressure gauges, and hot-metal families"), ("RG-04", "Quillroot Archive", "living root shelves and changing leaf patterns", "copied actions and target redirection", "repeat, alternate, or preserve archive clauses", "root stacks, leaf-script shapes, and copy/erase motion"), ("RG-05", "Tideglass Reach", "salt flats and mirror pools with rescue beacons", "decoys and delayed copies", "source and echo differ by shape and timing", "high-contrast salt/mirror layers and reflection timing"), ("RG-06", "Skyrail Orchard", "fruit terraces, freight rails, and strong updrafts", "moving order and falling arrivals", "rail cars announce two future stops", "orchard/rail parallax, stop glyphs, and board/leap/fall families"), ("RG-07", "Nightcurrent Basin", "dark water channels and luminous current", "forecast reveal and resource theft", "one current sweeps front to rear before major action", "dark readable basin, luminance-safe reveal shapes, and current-link VFX"), ("RG-08", "Crown of Morn", "dawnlit stone ring at the high route nexus", "prior-rule synthesis and replacement depth", "two known grammars name inherited rule sources", "dawn nexus, eight current motifs, and synthesis action families"), ) region_lines = [ "# Region Catalog", "", "## Region Matrix", "", "| ID | Region and minimal framing | New behavior and build pressure | Encounter grammar and progression | Content assignment | Production needs |", "|---|---|---|---|---|---|", ] for identifier, name, biome, behavior, hazard, production in region_rows: region_lines.append( "| `{}` | **{}**; {}. | {}. | {}. | assigned identities. | {}. |".format( identifier, name, biome, behavior, hazard, production ) ) (root / "docs/content/REGION_CATALOG.md").write_text( "\n".join(region_lines) + "\n", encoding="utf-8" ) (root / "docs/content/CONTENT_INDEX.md").write_text( "# Content Index\n\n```sh\n! rg -ni 'relationship|romance|six-unit|Directive|Anchor|Cache|three-lane' docs/content\n```\n", encoding="utf-8", ) recruit = """# Generated Recruit Authority ## 1. Recruit Record ```text recruit_instance_id, generation_seed, offer_index display_name_tokens, pronoun_set, voice_effort_set body_frame, face_shape, skin_palette, hair_shape, hair_palette garment_cut, garment_palette, profession_overlay, detail_mark ``` ### 2.1 Opening Four-Offer Binding Opening generation is legal only from a validated, read-back `run_creation_draft` created by an applied seed-reservation operation. All four complete records, their offer IDs, and output hashes enter one materialization commit. Selecting or revising the accepted exact pair does not regenerate or mutate an offer. ## 4. Modular Visual Grammar | Part | Options | Rule | |---|---:|---| | Body frame | 8 | deterministic option | | Face shape | 12 | deterministic option | | Skin palette | 12 | deterministic option | | Hair/head shape | 24 | deterministic option | | Hair palette | 10 | deterministic option | | Garment cut | 12 | deterministic option | | Garment palette | 16 | deterministic option | | Profession overlay | 42 | deterministic option | | Detail mark | 20 | deterministic option | | Voice effort set | 8 | audio-owned option | """ (root / RECRUIT_PATH).write_text(recruit, encoding="utf-8") page_rows = ( ("SHP-001", "Shop offers", "Buy recruit", "empty, error, confirmation"), ("REC-001", "Recruit hiring", "Hire", "empty, error, confirmation"), ("EQP-001", "Equipment loadout", "Equip", "empty, error, confirmation"), ("PTY-001", "Party line formation reorder", "Reorder", "empty, error, confirmation"), ("BAT-001", "Automatic battle", "Start battle", "pause, speed, inspect, retreat"), ("OUT-001", "Victory result and reward", "Take reward", "empty, error, confirmation"), ("OUT-002", "Failure death and recovery", "Continue", "empty, error, confirmation"), ("OUT-006", "Run abandonment review", "Abandon run", "empty, error, confirmation"), ("SYS-001", "Save and cloud", "Manage save", "empty, error, confirmation"), ( "SYS-005", "New run provisional choice", "Choose two provisional Recruits from four generated offers", "Selection is revisable before Run creation; cancel creates no run", ), ("SET-001", "Settings and controls", "Apply", "input, empty, error, confirmation"), ("SET-002", "Accessibility assists", "Apply", "screen reader, reduced motion, captions"), ("SET-003", "Language localization", "Apply language", "locale, text expansion"), ("PRO-001", "Progression records", "Inspect", "empty, error, confirmation"), ("PST-003", "Endgame postgame", "Begin postgame", "empty, error, confirmation"), ) screen_lines = [ "# Screen and State Map", "", "Landscape composition is mandatory. Every page covers empty and error states, confirmation behavior, input, localization, and accessibility.", "", "| Binding | Use | Owner | Missing behavior |", "|---|---|---|---|", "| `BIND:PARTY_CAPACITY` | line capacity | core systems | block dependent action |", "", "| ID | Page/state | Composition | Primary action | States and exit |", "|---|---|---|---|---|", ] screen_lines.extend( "| `{}` | {} | stable landscape composition | {} | {} |".format(*row) for row in page_rows ) screen_lines.extend( ( "", "## 7. Onboarding Map", "", "| Moment | Base screen | Required action | Safe boundary |", "|---|---|---|---|", "| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |", ) ) (root / "docs/presentation/SCREEN_AND_STATE_MAP.md").write_text( "\n".join(screen_lines) + "\n", encoding="utf-8" ) (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( "# UI\n\nLandscape input, localization, and accessibility rules are shared.\n" "The party cord runs from First exposed at left to Last at right.\n" "Progression coverage is `PRO-001..PST-003`.\n" "| Binding | Use | Owner |\n|---|---|---|\n" "| `BIND:AUDIO_EVENT_CATALOG` | semantic events | audio catalog |\n", encoding="utf-8", ) art = """# Art Asset and Animation Catalog Modular generated recruits use a landscape composition. Equipment readability uses silhouettes and icons. The animation matrix and VFX language are production-countable. The player party reads left-to-right from exposed front to protected rear. Profession coverage: `ART-PRO-B01..B12`, `ART-PRO-A01..A24`, `ART-PRO-H01..H06`. Trait coverage: `ART-TRT-01..36`. Equipment coverage: `ART-EQP-001..320`. Artifact coverage: `ART-AFT-01..60`. Enemy coverage: `ART-ENM-001..100`; boss coverage: `ART-BOS-01..16`. Region coverage: `ART-REG-01..08`. ## 4. Generated Recruit Visual System ### 4.2 Modular Source Inventory | Library | Exact masters | Runtime states | |---|---:|---:| | Body foundations | 8 | 1 | | Face structures | 12 | 1 | | Skin ramps | 12 | 1 | | Hair families | 24 | 1 | | Hair ramps | 10 | 1 | | Underlayers | 12 | 1 | | Underlayer ramps | 16 | 1 | | Profession kits | 42 | 1 | | Face detail sets | 20 | 1 | ### 4.3 Recruit Content/Art Crosswalk | Content field | Content options | Art source | Art masters | Mapping rule | |---|---:|---|---:|---| | `body_frame` | 8 | Body foundations | 8 | same stable index | | `face_shape` | 12 | Face structures | 12 | same stable index | | `skin_palette` | 12 | Skin ramps | 12 | same stable index | | `hair_shape` | 24 | Hair families | 24 | same stable index | | `hair_palette` | 10 | Hair ramps | 10 | same stable index | | `garment_cut` | 12 | Underlayers | 12 | same stable index | | `garment_palette` | 16 | Underlayer ramps | 16 | same stable index | | `profession_overlay` | 42 | Profession kits | 42 | same stable index | | `detail_mark` | 20 | Face detail sets | 20 | same stable index | | `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value | ### 9.2 Eight Region Keys | Art ID | Exact content binding | Region name, biome, and landmark brief | Palette, light, and material brief | Hazard/mechanic readability brief | Motion and occlusion rule | |---|---|---|---|---|---| | `ART-REG-01` | `RG-01` | **Windglass Verge**: open grass terraces and suspended mineral panes. | Bright terrace layers and wind direction shapes. | Glass-wind push telegraphs one cadence ahead with front reach and projectile screens. | Wind layers hold in reduced motion. | | `ART-REG-02` | `RG-02` | **Siltwake Fens**: reed channels, luminous sediment, and movable banks. | Layered reeds, waterline, and non-color tide notches. | Tide markers shift the safe line index around pulls and hindrance expiry. | Reeds lower around actors. | | `ART-REG-03` | `RG-03` | **Emberloom Works**: communal kilns and ceramic conduits. | Kiln layers, pressure gauges, and hot-metal families. | One pressure valve changes a known countdown for constructs and delayed bursts. | Heat shimmer stays local. | | `ART-REG-04` | `RG-04` | **Quillroot Archive**: living root shelves and changing leaf patterns. | Root stacks, leaf-script shapes, and copy/erase motion. | Repeat, alternate, or preserve archive clauses show copied actions and target redirection. | Roots fade before forecasts. | | `ART-REG-05` | `RG-05` | **Tideglass Reach**: salt flats and mirror pools with rescue beacons. | High-contrast salt/mirror layers and reflection timing. | Source and echo differ by shape and timing for decoys and delayed copies. | Reflections hold in low power. | | `ART-REG-06` | `RG-06` | **Skyrail Orchard**: fruit terraces, freight rails, and strong updrafts. | Orchard/rail parallax, stop glyphs, and board/leap/fall families. | Rail cars announce two future stops for moving order and falling arrivals. | Cars hold at indexed stops. | | `ART-REG-07` | `RG-07` | **Nightcurrent Basin**: dark water channels and luminous current. | Dark readable basin, luminance-safe reveal shapes, and current-link VFX. | One current sweeps front to rear before major action, revealing forecasts and resource theft. | Current light advances in bands. | | `ART-REG-08` | `RG-08` | **Crown of Morn**: dawnlit stone ring at the high route nexus. | Dawn nexus, eight current motifs, and synthesis action families. | Two known grammars name inherited rule sources for prior-rule synthesis and replacement depth. | Ring motion pauses for forecasts. | ### 5.4 Hidden Hybrid Profession Kits: 6 | Art ID / content identity | Exact parent pair | Combat-to-silhouette translation | Held props and anchors | Material | VFX / event cue | Animation needs / action verbs | |---|---|---|---|---|---|---| | `ART-PRO-H01` / `CONTENT.PROFESSION.HIDDEN[01]` / `PF-H01` Wardmender | `PF-B01` + `PF-B07` | A mantle buckler braces intercepted harm and opens repair energy toward the protected ally. | Mantle buckler and repair lamp. | iron and cloth | A square pulse closes with a repair chime. | `BRACE` (brace/mend) | | `ART-PRO-H02` / `CONTENT.PROFESSION.HIDDEN[02]` / `PF-H02` Tempo Cutter | `PF-B02` + `PF-B11` | Bell blades show cadence Edge shortening an enemy telegraph between ally actions. | Paired bell blades and beat tabs. | steel and brass | A split metronome marks cadence. | `MOBILE` (step/cut/strike) | | `ART-PRO-H03` / `CONTENT.PROFESSION.HIDDEN[03]` / `PF-H03` Farline Lancer | `PF-B03` + `PF-B05` | A bow-spear plants a guide for a piercing rear shot and recovery. | Bow-spear with a ground plant. | wood and steel | One horizon line marks the guided shot. | `RELEASE` (plant/draw/recover) | | `ART-PRO-H04` / `CONTENT.PROFESSION.HIDDEN[04]` / `PF-H04` Route Smith | `PF-B04` + `PF-B09` | A pack automaton changes guard, strike, and pull mode at each indexed position. | Pack automaton with a ground anchor. | steel and canvas | Numbered footprints close with a mode click. | `DEPLOY` (carry/drop/switch) | | `ART-PRO-H05` / `CONTENT.PROFESSION.HIDDEN[05]` / `PF-H05` Eclipse Scribe | `PF-B06` + `PF-B10` | A split tablet stores interrupted charge as shadow text until hindrance expiry. | Split tablet at the effect anchor. | glass and wood | An eclipsed diamond reverses the charge. | `CHANNEL` (collapse/inscribe/release) | | `ART-PRO-H06` / `CONTENT.PROFESSION.HIDDEN[06]` / `PF-H06` Loan Factor | `PF-B08` + `PF-B12` | A ledger-knot frame shows linked loans repaid as flexible Credit after recipient actions. | Ledger-knot frame and linked loan tabs. | wood and cord | Linked tabs close with a stamped pluck. | `PULSE` (loan/mark/close) | """ (root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8") music_rows = [ "| `{}` | 1 | role |".format(identifier) for identifier in ( "MUS-G00-TITLE", "MUS-G01-GUILD", "MUS-G02-SHOP", "MUS-G03-PREP", "MUS-G04-RUN", "MUS-G05-RESULT-WIN", "MUS-G06-RESULT-LOSS", "MUS-G07-META", "MUS-R01..08-ROUTE", "MUS-R01..08-BATTLE", "MUS-R01..08-BOSS", ) ] voice_ids = list(numbered_ids("VO-SYS-", 16, 3)) + list(numbered_ids("VO-ANN-", 16, 3)) sfx_ids = ( list(numbered_ids("SFX-UI-", 24, 3)) + list(numbered_ids("SFX-SHP-", 24, 3)) + list(numbered_ids("SFX-EQP-", 20, 3)) + list(numbered_ids("SFX-ORD-", 20, 3)) + list(numbered_ids("SFX-BAT-", 60, 3)) + list(numbered_ids("SFX-RES-", 28, 3)) + list(numbered_ids("SFX-RUN-", 28, 3)) ) sfx_lines = [] for identifier in sfx_ids: role = "event_role" if identifier == "SFX-UI-007": role = "inspect_open" elif identifier == "SFX-SHP-020": role = "dismiss_commit" sfx_lines.append("{} {}".format(identifier, role)) region_sfx_rows = [] for region in range(1, 9): base = (region - 1) * 3 + 1 region_sfx_rows.append( "| R{:02d} | `SFX-REG-{:03d}` | `SFX-REG-{:03d}` | `SFX-REG-{:03d}` |".format( region, base, base + 1, base + 2 ) ) audio = "\n".join( [ "# Audio Music and Voice Catalog", "", "Shop offers, recruit hire, equipment, party reorder, battle, pause, speed, retreat, permanent casualty death, dismissal, reward, failure and defeat are covered.", "Generated recruits use bounded voice. Accessibility uses captions, visual cues, haptics, and mono review.", "", "| Dependency | Resolved source and value | Audio use |", "|---|---|---|", "| Screen and action IDs | `SYS-001`, `SYS-005`, `SHP-001`, `REC-001`, `EQP-001`, `PTY-001`, `BAT-001`, `OUT-001..002`, `OUT-006`, `SET-001..003`, `PRO-001`, `PST-003` | bind shared events to all 15 final screens |", "", "## 5. Music Plan", "", "### 5.1 Scope And Count", "", "| Suite family | Count | Role |", "|---|---:|---|", *music_rows, "", "## 6. Ambience Plan", "", "### 6.1 Configuration Count", "", "| Family | Configuration IDs | Count |", "|---|---|---:|", "| Global | `AMB-G01..G12` | 12 |", "| Per region | `AMB-R{01..08}-01..08` | 64 |", "", "## 7. Generated-Recruit Voice Contract", "", "### 7.1 Bounded Scope", "", "```text", *("{} semantic_role".format(identifier) for identifier in voice_ids), "```", "", "## 8. Shared SFX Event Ledger", "", "### 8.1 Shared Definitions", "", "```text", *sfx_lines, "```", "", "### 8.8 Eight-Region Progression: 24", "", "| Region | Entry | Hazard | Result |", "|---|---|---|---|", *region_sfx_rows, "", ] ) (root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").write_text( audio, encoding="utf-8" ) (root / "docs/reference").mkdir(parents=True, exist_ok=True) (root / REFERENCE_SCOPE_PATH).write_text( "# Transferable Structure Lessons\n\n" "Historical evidence mentioned profession affinities, relationship hooks, and 30 named recruitable characters; this authority rejects those retired concepts.\n", encoding="utf-8", ) def replace_fixture_text(path: Path, old: str, new: str) -> None: text = path.read_text(encoding="utf-8") if old not in text: raise AssertionError("fixture rewrite target not found in {}: {}".format(path, old[:80])) path.write_text(text.replace(old, new, 1), encoding="utf-8") def rewrite_fixture_with_integrated_shapes(root: Path) -> None: screen_path = root / SCREEN_PATH replace_fixture_text( screen_path, "Choose two provisional Recruits from four generated offers", "Compare four stable provisional generated Recruit offers and choose exactly two before Run creation", ) replace_fixture_text( screen_path, "| Opening choice | `SYS-005` | Choose two provisional Recruits from four generated offers | Choice is revisable before Run creation |", "| Provisional Company | `SYS-005` stages `A-C` | inspect four stable offers and choose exactly two, then review both | selection alone creates no run |\n" "| Run creation | `SYS-005` stages `D-E` | confirm selected two and the unchosen consequence | `Revise Recruits` remains legal before commit |", ) replace_fixture_text( root / UI_PATH, "The party cord runs from First exposed at left to Last at right.", "The party cord runs from `Protected rear` at left to `Exposed front` at right.", ) art_path = root / ART_PATH replace_fixture_text( art_path, "The player party reads left-to-right from exposed front to protected rear.", "Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.", ) old_crosswalk = """### 4.3 Recruit Content/Art Crosswalk | Content field | Content options | Art source | Art masters | Mapping rule | |---|---:|---|---:|---| | `body_frame` | 8 | Body foundations | 8 | same stable index | | `face_shape` | 12 | Face structures | 12 | same stable index | | `skin_palette` | 12 | Skin ramps | 12 | same stable index | | `hair_shape` | 24 | Hair families | 24 | same stable index | | `hair_palette` | 10 | Hair ramps | 10 | same stable index | | `garment_cut` | 12 | Underlayers | 12 | same stable index | | `garment_palette` | 16 | Underlayer ramps | 16 | same stable index | | `profession_overlay` | 42 | Profession kits | 42 | same stable index | | `detail_mark` | 20 | Face detail sets | 20 | same stable index | | `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value | """ integrated_crosswalk = """### 4.3 Recruit Record To Art Crosswalk | Recruit record field | Stored authority | Art binding | Derived assembly and runtime output | |---|---|---|---| | `voice_effort_set` | Audio field with 8 content options | No art master | Audio binds independently to the recruit ID. | | `body_frame` | Visual grammar: 8 options | `ART-REC-BODY-01..08` | Shared body layers use the stored field. | | `face_shape` | Visual grammar: 12 options | `ART-REC-FACE-01..12` | Portrait assembly uses the stored field. | | `skin_palette` | Visual grammar: 12 options | `ART-REC-SKINPAL-01..12` | Palette data uses the stored field. | | `hair_shape` | Visual grammar: 24 options | `ART-REC-HAIR-01..24` | Hair assemblies use the stored field. | | `hair_palette` | Visual grammar: 10 options | `ART-REC-HAIRPAL-01..10` | Palette data uses the stored field. | | `garment_cut` | Visual grammar: 12 options | `ART-REC-GARMENT-01..12` | Garment layers use the stored field. | | `garment_palette` | Visual grammar: 16 options | `ART-REC-GARMENTPAL-01..16` | Palette data uses the stored field. | | `profession_overlay` | Visual grammar: 42 options | One `ART-PRO-B*`, `ART-PRO-A*`, or `ART-PRO-H*` package | The selected profession owns one binding. | | `detail_mark` | Visual grammar: 20 options | `ART-REC-DETAIL-01..20` | Detail assembly uses the stored field. | """ replace_fixture_text(art_path, old_crosswalk, integrated_crosswalk) def mutate_integrated_provisional_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text( root / SCREEN_PATH, "choose exactly two before Run creation", "choose exactly one before Run creation", ) def mutate_integrated_direction_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text( root / ART_PATH, "Screen-left is the protected rear and `Last`; screen-right is `First exposed` and the party front.", "Screen-left is `First exposed` and the party front; screen-right is the protected rear and `Last`.", ) def mutate_integrated_crosswalk_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text(root / ART_PATH, "`ART-REC-BODY-01..08`", "`ART-REC-BODY-01..07`") def mutate_integrated_audio_screen_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) replace_fixture_text(root / AUDIO_PATH, "`OUT-006`, ", "") def mutate_integrated_reference_scope_fixture(root: Path) -> None: rewrite_fixture_with_integrated_shapes(root) path = root / REFERENCE_SCOPE_PATH path.write_text( path.read_text(encoding="utf-8") + "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n", encoding="utf-8", ) def mutate_seed_order_fixture(root: Path) -> None: replace_fixture_text( root / SAVE_PATH, "read back before the opening generator may run", "read back after the opening generator may run", ) def mutate_incomplete_run_creation_transaction_fixture(root: Path) -> None: replace_fixture_text( root / SAVE_PATH, "Commit is one atomic state:", "Commit creates a state:", ) def mutate_hidden_profession_mechanic_fixture(root: Path) -> None: art_path = root / ART_PATH replacements = ( ( "Bell blades show cadence Edge shortening an enemy telegraph between ally actions.", "A bow arc crosses a rear vial wing while the front hand stays clear.", ), ("Paired bell blades and beat tabs.", "A long bow, rear vials, and a projectile anchor."), ("A split metronome marks cadence.", "A mist cloud marks deployed ammunition."), ("`MOBILE` (step/cut/strike)", "`RELEASE` (draw/deploy)"), ) for old, new in replacements: replace_fixture_text(art_path, old, new) def mutate_hidden_profession_unresolved_fixture(root: Path) -> None: replace_fixture_text( root / ART_PATH, "| `PF-B02` + `PF-B11` |", "| `CONTENT.PROFESSION.HIDDEN[02].parents` |", ) def mutate_region_art_missing_mapping_fixture(root: Path) -> None: replace_fixture_text( root / ART_PATH, "| `ART-REG-01` | `RG-01` |", "| `ART-REG-01` | |", ) def mutate_region_art_wrong_id_fixture(root: Path) -> None: path = root / ART_PATH text = path.read_text(encoding="utf-8") first = "| `ART-REG-02` | `RG-02` |" second = "| `ART-REG-03` | `RG-03` |" if first not in text or second not in text: raise AssertionError("fixture region-binding swap targets not found") text = text.replace(first, "| `ART-REG-02` | __REGION_SWAP__ |", 1) text = text.replace(second, "| `ART-REG-03` | `RG-02` |", 1) text = text.replace("| `ART-REG-02` | __REGION_SWAP__ |", "| `ART-REG-02` | `RG-03` |", 1) path.write_text(text, encoding="utf-8") def mutate_region_art_duplicate_target_fixture(root: Path) -> None: replace_fixture_text( root / ART_PATH, "| `ART-REG-02` | `RG-02` |", "| `ART-REG-02` | `RG-01` |", ) def mutate_region_art_mismatched_brief_fixture(root: Path) -> None: replace_fixture_text( root / ART_PATH, "**Quillroot Archive**: living root shelves and changing leaf patterns.", "**Quillroot Archive**: salt flats and mirror pools with rescue beacons.", ) def mutate_remove_line(path: Path, needle: str) -> None: lines = path.read_text(encoding="utf-8").splitlines() for index, line in enumerate(lines): if needle in line: del lines[index] path.write_text("\n".join(lines) + "\n", encoding="utf-8") return raise AssertionError("fixture mutation target not found: {}".format(needle)) def run_self_test() -> None: with tempfile.TemporaryDirectory(prefix="abg-design-validator-") as temporary: root = Path(temporary) write_fixture(root) passing = audit(root) if not passing.ok: rendered = "\n".join( "{} {}:{} {}".format(item.code, item.path, item.line, item.message) for item in passing.issues ) raise AssertionError("passing fixture failed:\n{}".format(rendered)) expected_counts = { "professions.base": 12, "professions.advanced": 24, "professions.hidden": 6, "traits": 36, "equipment": 320, "artifacts": 60, "enemies.ordinary_elite": 100, "bosses": 16, "regions": 8, "audio.music": 32, "audio.ambience": 76, "audio.sfx": 228, "audio.voice": 32, "pages": 15, "pages.audio_bound": 15, } for name, expected in expected_counts.items(): if passing.counts.get(name) != expected: raise AssertionError( "passing fixture count {}={} (expected {})".format( name, passing.counts.get(name), expected ) ) range_regressions = { "ART-PRO-B01..B12": 12, "AMB-G01..G12": 12, "AMB-R{01..08}-01..08": 64, "MUS-R01..08-BATTLE": 8, "PRO-001..PST-003": 2, } for expression, expected in range_regressions.items(): expanded = expand_compact_id_expression(expression) if expanded is None or len(expanded) != expected: raise AssertionError("range regression failed: {}".format(expression)) rewrite_fixture_with_integrated_shapes(root) integrated_shape = audit(root) if not integrated_shape.ok: rendered = "\n".join( "{} {}:{} {}".format(item.code, item.path, item.line, item.message) for item in integrated_shape.issues ) raise AssertionError("integrated-shape passing fixture failed:\n{}".format(rendered)) cases: Sequence[Tuple[str, Callable[[Path], None], str]] = ( ( "exact-count", lambda root: mutate_remove_line(root / "docs/content/ARTIFACT_CATALOG.md", "`AR-060`"), "REGISTRY_EXACT_SET", ), ( "duplicate-id", lambda root: (root / "docs/content/TRAIT_CATALOG.md").write_text( (root / "docs/content/TRAIT_CATALOG.md").read_text(encoding="utf-8") + "| `TR-001` | duplicate | duplicate | duplicate | duplicate | duplicate |\n", encoding="utf-8", ), "ID_DUPLICATE", ), ( "enemy-gap", lambda root: mutate_remove_line(root / "docs/content/ENEMY_CATALOG.md", "`EN-R4-06`"), "ENEMY_SEQUENCE_GAP", ), ( "broken-reference", lambda root: (root / "docs/product/SYSTEMS_AND_BATTLE.md").write_text( (root / "docs/product/SYSTEMS_AND_BATTLE.md").read_text(encoding="utf-8") + "Uses `TR-999`.\n", encoding="utf-8", ), "REFERENCE_BROKEN", ), ( "pending-binding", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Capacity is {{BINDING:deployment_capacity}}.\n", encoding="utf-8", ), "BINDING_UNRESOLVED", ), ( "bracket-binding", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Required value `[UNRESOLVED_VALUE]`.\n", encoding="utf-8", ), "BINDING_UNRESOLVED", ), ( "unknown-declared-binding", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Uses `BIND:UNKNOWN_SOURCE`.\n", encoding="utf-8", ), "BINDING_UNKNOWN", ), ( "unbounded-range", lambda root: (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").write_text( (root / "docs/presentation/UI_AND_VISUAL_SYSTEM.md").read_text(encoding="utf-8") + "Invalid range `PF-B01..PF-B5000`.\n", encoding="utf-8", ), "REFERENCE_RANGE_INVALID", ), ( "retired-concept", lambda root: (root / "docs/product/SYSTEMS_AND_BATTLE.md").write_text( (root / "docs/product/SYSTEMS_AND_BATTLE.md").read_text(encoding="utf-8") + "Players spend a Directive during battle.\n", encoding="utf-8", ), "RETIRED_DIRECTIVE", ), ( "filler-row", lambda root: (root / "docs/content/TRAIT_CATALOG.md").write_text( (root / "docs/content/TRAIT_CATALOG.md") .read_text(encoding="utf-8") .replace("| `TR-001` | decision-bearing", "| `TR-001` | TODO", 1), encoding="utf-8", ), "REGISTRY_ROW_PENDING", ), ( "page-coverage", lambda root: mutate_remove_line( root / "docs/presentation/SCREEN_AND_STATE_MAP.md", "`PST-003`" ), "PAGE_CAPABILITY_MISSING", ), ( "art-coverage", lambda root: mutate_remove_line( root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md", "ART-PRO-H01" ), "ART_ID_COVERAGE_MISSING", ), ( "audio-coverage", lambda root: mutate_remove_line( root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md", "SFX-SHP-020" ), "AUDIO_REGISTRY_EXACT_SET", ), ( "audio-duplicate", lambda root: (root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").write_text( (root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").read_text(encoding="utf-8") .replace( "SFX-UI-001 event_role", "SFX-UI-001 event_role\nSFX-UI-001 duplicate_role", 1, ), encoding="utf-8", ), "ID_DUPLICATE", ), ( "provisional-selection-presentation", mutate_integrated_provisional_fixture, "PROVISIONAL_SELECTION_PRESENTATION_MISMATCH", ), ( "party-direction", mutate_integrated_direction_fixture, "PARTY_DIRECTION_MISMATCH", ), ( "recruit-art-crosswalk", mutate_integrated_crosswalk_fixture, "RECRUIT_ART_CROSSWALK_MISMATCH", ), ( "audio-screen-inventory", mutate_integrated_audio_screen_fixture, "AUDIO_SCREEN_INVENTORY_MISMATCH", ), ( "reference-retired-scope", mutate_integrated_reference_scope_fixture, "REFERENCE_RETIRED_SCOPE", ), ( "run-creation-seed-order", mutate_seed_order_fixture, "RUN_CREATION_SEED_ORDER_MISMATCH", ), ( "run-creation-transaction", mutate_incomplete_run_creation_transaction_fixture, "RUN_CREATION_TRANSACTION_INCOMPLETE", ), ( "hidden-profession-mechanic", mutate_hidden_profession_mechanic_fixture, "HIDDEN_PROFESSION_ART_MISMATCH", ), ( "hidden-profession-unresolved", mutate_hidden_profession_unresolved_fixture, "HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED", ), ( "region-art-missing-mapping", mutate_region_art_missing_mapping_fixture, "REGION_ART_BINDING_MISMATCH", ), ( "region-art-wrong-id", mutate_region_art_wrong_id_fixture, "REGION_ART_BINDING_MISMATCH", ), ( "region-art-duplicate-target", mutate_region_art_duplicate_target_fixture, "REGION_ART_BINDING_MISMATCH", ), ( "region-art-mismatched-brief", mutate_region_art_mismatched_brief_fixture, "REGION_ART_BINDING_MISMATCH", ), ) isolated_semantic_cases = { "provisional-selection-presentation", "party-direction", "recruit-art-crosswalk", "audio-screen-inventory", "reference-retired-scope", "run-creation-seed-order", "run-creation-transaction", "hidden-profession-mechanic", "hidden-profession-unresolved", "region-art-missing-mapping", "region-art-wrong-id", "region-art-duplicate-target", "region-art-mismatched-brief", } for name, mutate, expected_code in cases: with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary: root = Path(temporary) write_fixture(root) mutate(root) result = audit(root) codes = {item.code for item in result.issues} if expected_code not in codes: raise AssertionError( "{} did not produce {}; got {}".format(name, expected_code, sorted(codes)) ) if name in isolated_semantic_cases and codes != {expected_code}: raise AssertionError( "{} was not isolated to {}; got {}".format( name, expected_code, sorted(codes) ) ) print("ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases={}".format(len(cases) + 1)) def print_result(result: AuditResult, as_json: bool) -> None: if as_json: print( json.dumps( { "ok": result.ok, "errors": len(result.issues), "counts": result.counts, "diagnostics": [item.as_dict() for item in result.issues], }, indent=2, sort_keys=True, ) ) return for item in result.issues: print( "ERROR {} {}:{}:{} {}".format( item.code, item.path, item.line, item.column, item.message ) ) print("COUNTS {}".format(json.dumps(result.counts, sort_keys=True, separators=(",", ":")))) if result.ok: print("ABG_PREDEVELOPMENT_DESIGN_OK errors=0") else: print("ABG_PREDEVELOPMENT_DESIGN_FAILED errors={}".format(len(result.issues))) def parse_arguments(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "root", nargs="?", type=Path, default=REPOSITORY_ROOT, help="repository root (default: validator's repository)", ) parser.add_argument("--json", action="store_true", help="emit deterministic JSON diagnostics") parser.add_argument("--self-test", action="store_true", help="run embedded pass and negative fixtures") return parser.parse_args(argv) def main(argv: Optional[Sequence[str]] = None) -> int: arguments = parse_arguments(argv) if arguments.self_test: run_self_test() return 0 result = audit(arguments.root) print_result(result, arguments.json) return 0 if result.ok else 1 if __name__ == "__main__": sys.exit(main())