Files
aetherbound-guild/tools/validate_predevelopment_design.py
T

2959 lines
111 KiB
Python
Executable File

#!/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"(?<![A-Z0-9_-])([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)(?![A-Z0-9_-])"
)
RANGE_IN_TEXT_RE = re.compile(
r"(?<![A-Z0-9_-])"
r"([A-Z][A-Z0-9-]*?\d+)\.\."
r"((?:[A-Z][A-Z0-9-]*?)?\d+(?:-[A-Z][A-Z0-9-]*)?)"
r"(?![A-Z0-9_-])"
)
CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
BINDING_ID_RE = re.compile(r"BIND:[A-Z][A-Z0-9_]+")
BRACKET_BINDING_RE = re.compile(r"\[[A-Z][A-Z0-9_{}.-]*(?:\.\.[A-Z0-9_{}.-]+)?\]")
NAMED_PRESENTATION_ID_RE = re.compile(
r"^(?:MUS|MUSIC|AMB|AMBIENCE|SFX|UI|VO|VOC|VOICE|ART|AST|VFX|ANIM)-"
)
PENDING_PATTERNS = (
re.compile(r"\b(?:TBD|TBC|TODO|FIXME)\b"),
re.compile(r"\{\{\s*(?:binding\s*[:.]?\s*)?[^{}]+\}\}", re.IGNORECASE),
re.compile(
r"(?:\[\[|<)?\bBINDING\s*(?::|\(|\[)\s*[A-Za-z0-9_.-]+",
re.IGNORECASE,
),
)
RETIREMENT_CONTEXT_RE = re.compile(
r"\b(?:"
r"no|not|never|without|remove|removes|removed|removing|retire|retired|"
r"obsolete|forbid|forbidden|reject|rejected|exclude|excluded|"
r"do\s+not|must\s+not|no\s+longer"
r")\b",
re.IGNORECASE,
)
NEGATIVE_SEARCH_RE = re.compile(r"!\s*(?:rg|grep)\b", re.IGNORECASE)
RETIRED_CONCEPTS = (
(
"RETIRED_FIXED_CAST",
re.compile(
r"\b(?:fixed[- ]cast|fixed\s+(?:named\s+)?(?:protagonists?|characters?|heroes?)|"
r"30\s+(?:fixed|named|authored)\s+(?:protagonists?|characters?|heroes?))\b",
re.IGNORECASE,
),
),
(
"RETIRED_RELATIONSHIP_SYSTEM",
re.compile(
r"\b(?: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,
),
),
(
"RETIRED_SIX_UNIT_CAP",
re.compile(
r"\b(?:six|6)[- ](?:unit|person|member|adventurer)\b|"
r"\bfixed\s+(?:deployment|roster|party)\s+cap\s+(?:of\s+)?(?:six|6)\b",
re.IGNORECASE,
),
),
(
"RETIRED_THREE_LANE_GRID",
re.compile(
r"\b(?:three|3)[- ]lane(?:\s+grid)?\b|"
r"\b(?:three|3)[- ]band\s+(?:grid|formation|battlefield|layout|positioning)\b",
re.IGNORECASE,
),
),
("RETIRED_DIRECTIVE", re.compile(r"\bDirective(?:s)?\b")),
("RETIRED_ANCHOR", re.compile(r"\bAnchor(?:s)?\b")),
("RETIRED_CACHE", re.compile(r"\bCache(?:s)?\b")),
(
"RETIRED_MANUAL_BATTLE_COMMAND",
re.compile(
r"\b(?:manual(?:ly)?\s+(?:activate|cast|trigger|command|intervene)|"
r"in[- ]battle\s+(?:command|intervention)|skill\s+activation)\b",
re.IGNORECASE,
),
),
)
@dataclass(frozen=True)
class Issue:
code: str
path: str
line: int
column: int
message: str
def sort_key(self) -> 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
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"
RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.md"
UI_PATH = "docs/presentation/UI_AND_VISUAL_SYSTEM.md"
ART_PATH = "docs/presentation/ART_ASSET_AND_ANIMATION_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}")
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<selected>{})\b"
r"(?=[^\n|.]{{0,120}}\bprovisional\b)"
r"(?=[^\n|.]{{0,120}}\brecruits?\b)"
r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P<offered>{})\b".format(
COUNT_TOKEN, COUNT_TOKEN
),
re.IGNORECASE,
)
PROVISIONAL_SELECTED_COUNT_RES = (
re.compile(
r"\b(?:choose|select)\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
re.IGNORECASE,
),
re.compile(
r"\b(?:with\s+)?(?:exactly\s+)?(?P<count>{})\s+selected\b".format(COUNT_TOKEN),
re.IGNORECASE,
),
re.compile(
r"\bselected\s+(?:exactly\s+)?(?P<count>{})\b".format(COUNT_TOKEN),
re.IGNORECASE,
),
)
PROVISIONAL_OFFERED_COUNT_RES = (
re.compile(
r"\b(?P<count>{})\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<count>{})\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 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<concept>{})\b[^.;|]{{0,40}}?\b(?P<side>{})\b".format(
PARTY_ENDPOINT_EXPRESSION, PARTY_SIDE_EXPRESSION
),
re.IGNORECASE,
)
side_then_concept_re = re.compile(
r"\b(?P<side>{})\b\s*(?:is|=|:)\s*(?:the\s+)?"
r"(?P<concept>{})\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"(?<![A-Z0-9-])\d+(?![A-Z0-9-])", value)}
return next(iter(counts)) if len(counts) == 1 else None
def bounded_art_binding_count(value: str) -> 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_party_direction(documents, issues)
validate_recruit_art_crosswalk(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\n\nDeath, failure, rollback, and recovery are explicit.\n",
}
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", ""))
profession.append(table(numbered_ids("PF-H", 6, 2), 5))
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"
)
(root / "docs/content/REGION_CATALOG.md").write_text(
"# Region Catalog\n\n## Region Matrix\n\n" + table(numbered_ids("RG-", 8, 2), 5),
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
```
## 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 |
"""
(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_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",
),
)
isolated_semantic_cases = {
"provisional-selection-presentation",
"party-direction",
"recruit-art-crosswalk",
"audio-screen-inventory",
"reference-retired-scope",
}
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())