Files
aetherbound-guild/tools/validate_predevelopment_design.py
T

1270 lines
46 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/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]+)`")
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,
)
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|band)(?:\s+grid)?\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
pattern: re.Pattern
expected_ids: Tuple[str, ...]
minimum_cells: 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", re.compile(r"^PR-B\d{2}$"), numbered_ids("PR-B", 12, 2), 3),
RegistryRule(
"professions.advanced",
re.compile(r"^PR-A\d{2}$"),
numbered_ids("PR-A", 24, 2),
3,
),
RegistryRule("professions.hidden", re.compile(r"^PR-H\d{2}$"), numbered_ids("PR-H", 6, 2), 3),
RegistryRule("traits", re.compile(r"^TR-\d{3}$"), numbered_ids("TR-", 36, 3), 3),
RegistryRule("artifacts", re.compile(r"^AR-\d{3}$"), numbered_ids("AR-", 60, 3), 3),
RegistryRule("bosses", re.compile(r"^EN-B\d{3}$"), numbered_ids("EN-B", 16, 3), 3),
RegistryRule("regions", re.compile(r"^REG-\d{2}$"), numbered_ids("REG-", 8, 2), 3),
)
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 collect_definitions(documents: Sequence[Document]) -> Tuple[List[Definition], List[Issue]]:
definitions: List[Definition] = []
issues: List[Issue] = []
for document in documents:
for line_number, line in enumerate(document.text.splitlines(), 1):
cells = split_markdown_row(line)
if not cells:
continue
found = first_cell_identifier(cells[0])
if not found:
continue
identifier, cell_column = found
definitions.append(
Definition(
identifier,
document.path,
line_number,
max(1, line.find(cells[0]) + cell_column),
cells,
)
)
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 definition_is_content(definition: Definition) -> bool:
return definition.path.startswith("docs/content/")
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_is_content(definition) 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]:
item_definitions = [
definition
for definition in definitions
if definition_is_content(definition) and re.fullmatch(r"IT-\d{3}", definition.identifier)
]
equipment_definitions = [
definition
for definition in definitions
if definition_is_content(definition) and re.fullmatch(r"EQ-\d{3}", definition.identifier)
]
if item_definitions and equipment_definitions:
issues.append(
issue(
"EQUIPMENT_NAMESPACE_AMBIGUOUS",
"docs/content",
1,
1,
"equipment definitions mix IT-### and EQ-###; use exactly one namespace",
)
)
chosen = equipment_definitions if equipment_definitions else item_definitions
prefix = "EQ-" if equipment_definitions else "IT-"
actual_ids = {definition.identifier for definition in chosen}
expected_ids = set(numbered_ids(prefix, 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 {}001..{}320; found {}{}".format(
prefix,
prefix,
len(actual_ids),
"; " + "; ".join(detail) if detail else "",
),
)
)
for definition in chosen:
if len(definition.cells) < 3 or any(not cell.strip() for cell in definition.cells[:3]):
issues.append(
issue(
"REGISTRY_ROW_SCHEMA",
definition.path,
definition.line,
definition.column,
"{} requires at least three nonempty structural 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_is_content(definition) and re.fullmatch(r"EN-[OE]\d{3}", 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-O### or EN-E### IDs; found {}".format(
len(actual_ids)
),
)
)
for kind in ("O", "E"):
numbers = sorted(
int(identifier[-3:]) for identifier in actual_ids if identifier.startswith("EN-{}".format(kind))
)
if not numbers:
issues.append(
issue(
"ENEMY_CATEGORY_EMPTY",
"docs/content",
1,
1,
"EN-{} ordinary/elite category has no definitions".format(kind),
)
)
continue
expected = set(range(1, max(numbers) + 1))
missing = expected - set(numbers)
if missing:
issues.append(
issue(
"ENEMY_SEQUENCE_GAP",
"docs/content",
1,
1,
"EN-{} numbering must be gap-free from 001; missing [{}]".format(
kind,
format_id_sample("EN-{}{:03d}".format(kind, number) for number in missing),
),
)
)
for definition in matches:
if len(definition.cells) < 3 or any(not cell.strip() for cell in definition.cells[:3]):
issues.append(
issue(
"REGISTRY_ROW_SCHEMA",
definition.path,
definition.line,
definition.column,
"{} requires at least three nonempty structural 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 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()
if right_prefix and right_prefix != left_prefix:
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))
suffix = suffix or ""
return tuple(
"{}{:0{}d}{}".format(left_prefix, number, width, suffix)
for number in range(start, end + 1)
)
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 RANGE_IN_TEXT_RE.finditer(line):
expanded = parse_range(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(("PR-B", "PR-A", "PR-H", "TR-", "IT-", "EQ-", "AR-", "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_pending_bindings(documents: Sequence[Document], issues: List[Issue]) -> None:
observed: Set[Tuple[str, int, int, str]] = set()
for document in documents:
for line_number, line in enumerate(document.text.splitlines(), 1):
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
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 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],
required_ids: Dict[str, Set[str]],
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)
groups = (
("all 42 professions", required_ids["professions"]),
("all eight regions", required_ids["regions"]),
("all 100 ordinary/elite enemies", required_ids["enemies"]),
("all 16 bosses", required_ids["bosses"]),
)
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],
references: Sequence[Reference],
required_region_ids: Set[str],
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]
families = {
"music": re.compile(r"^(?:MUS|MUSIC)-"),
"ambience": re.compile(r"^(?:AMB|AMBIENCE)-"),
"sfx": re.compile(r"^(?:SFX|UI)-"),
"voice": re.compile(r"^(?:VO|VOC|VOICE)-"),
}
for family, pattern in families.items():
family_ids = {definition.identifier for definition in audio_definitions if pattern.match(definition.identifier)}
counts["audio.{}".format(family)] = len(family_ids)
if not family_ids:
issues.append(
issue(
"AUDIO_REGISTRY_FAMILY_MISSING",
path,
1,
1,
"no stable {} production IDs were found".format(family),
)
)
audio_region_ids = reference_ids_for_path(references, path)
missing_regions = required_region_ids - audio_region_ids
if missing_regions:
issues.append(
issue(
"AUDIO_REGION_COVERAGE_MISSING",
path,
1,
1,
"regional audio lacks explicit traceability: [{}]".format(
format_id_sample(missing_regions)
),
)
)
require_text_coverage(
document.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)
definitions, definition_issues = collect_definitions(documents)
issues.extend(definition_issues)
counts: Dict[str, int] = {}
required_ids: Dict[str, Set[str]] = {}
profession_ids: Set[str] = set()
for rule in REGISTRY_RULES:
actual = validate_registry_rule(rule, definitions, issues, counts)
if rule.name.startswith("professions."):
profession_ids.update(actual)
elif rule.name == "bosses":
required_ids["bosses"] = actual
elif rule.name == "regions":
required_ids["regions"] = actual
required_ids["professions"] = profession_ids
required_ids["equipment"] = validate_equipment_registry(definitions, issues, counts)
required_ids["enemies"] = validate_enemy_registry(definitions, issues, counts)
required_ids.setdefault("bosses", set())
required_ids.setdefault("regions", set())
references, reference_issues = collect_references(documents)
issues.extend(reference_issues)
validate_cross_references(definitions, references, issues)
validate_pending_bindings(documents, issues)
validate_retired_concepts(documents, issues)
validate_screen_coverage(documents, definitions, issues, counts)
validate_art_coverage(documents, references, required_ids, issues)
validate_audio_coverage(
documents,
definitions,
references,
required_ids["regions"],
issues,
counts,
)
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 markdown_table(rows: Iterable[Tuple[str, str, str]]) -> str:
output = ["| ID | Decision | Production |", "|---|---|---|"]
output.extend("| `{}` | {} | {} |".format(*row) for row in rows)
return "\n".join(output) + "\n"
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",
"SYSTEMS_AND_BATTLE.md": "# Systems\n\nAutomatic battle supports pause, speed, inspect, and retreat.\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")
catalog_rows: List[Tuple[str, str, str]] = []
families = (
numbered_ids("PR-B", 12, 2),
numbered_ids("PR-A", 24, 2),
numbered_ids("PR-H", 6, 2),
numbered_ids("TR-", 36, 3),
numbered_ids("IT-", 320, 3),
numbered_ids("AR-", 60, 3),
numbered_ids("EN-O", 60, 3),
numbered_ids("EN-E", 40, 3),
numbered_ids("EN-B", 16, 3),
numbered_ids("REG-", 8, 2),
)
for identifiers in families:
catalog_rows.extend((identifier, "changes a decision", "countable scope") for identifier in identifiers)
(root / "docs/content/CATALOGS.md").write_text(
"# Content Registries\n\n" + markdown_table(catalog_rows), encoding="utf-8"
)
page_rows = (
("PG-001", "Shop offers", "Buy recruit", "empty, error, confirmation"),
("PG-002", "Recruit hiring", "Hire", "empty, error, confirmation"),
("PG-003", "Equipment loadout", "Equip", "empty, error, confirmation"),
("PG-004", "Party line formation reorder", "Reorder", "empty, error, confirmation"),
("PG-005", "Automatic battle", "Start battle", "pause, speed, inspect, retreat"),
("PG-006", "Victory result and reward", "Take reward", "empty, error, confirmation"),
("PG-007", "Failure death and recovery", "Continue", "empty, error, confirmation"),
("PG-008", "Save and cloud", "Manage save", "empty, error, confirmation"),
("PG-009", "Settings and controls", "Apply", "input, empty, error, confirmation"),
("PG-010", "Accessibility assists", "Apply", "screen reader, reduced motion, captions"),
("PG-011", "Language localization", "Apply language", "locale, text expansion"),
("PG-012", "Endgame mastery", "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.",
"",
"| ID | Page/state | Composition | Primary action | States and exit |",
"|---|---|---|---|---|",
]
screen_lines.extend(
"| `{}` | {} | stable landscape composition | {} | {} |".format(*row) for row in page_rows
)
(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",
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.
Profession coverage: `PR-B01..PR-B12`, `PR-A01..PR-A24`, `PR-H01..PR-H06`.
Region coverage: `REG-01..REG-08`.
Enemy coverage: `EN-O001..EN-O060`, `EN-E001..EN-E040`, `EN-B001..EN-B016`.
"""
(root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8")
audio_rows = (
("MUS-REGION", "music for `REG-01..REG-08`", "eight regional mixes"),
("AMB-REGION", "ambience for `REG-01..REG-08`", "loop lifecycle"),
("SFX-SHOP", "shop offers", "event cue"),
("SFX-RECRUIT", "recruit hiring", "event cue"),
("SFX-EQUIP", "equipment loadout", "event cue"),
("SFX-REORDER", "party line reorder", "event cue"),
("SFX-BATTLE", "battle start", "event cue"),
("SFX-PAUSE", "pause", "event cue"),
("SFX-SPEED", "speed change", "event cue"),
("SFX-INSPECT", "inspect", "event cue"),
("SFX-RETREAT", "retreat", "event cue"),
("SFX-DEATH", "permanent casualty death", "event cue"),
("SFX-DISMISSAL", "dismissal", "event cue"),
("SFX-REWARD", "reward", "event cue"),
("SFX-FAILURE", "failure and defeat", "event cue"),
("SFX-ACCESS", "accessibility visual cue, caption, haptic, and mono", "fallback"),
("VO-GENERATED", "voice scope for generated recruits; no fixed cast", "bounded pool"),
)
(root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md").write_text(
"# Audio Music and Voice Catalog\n\n" + markdown_table(audio_rows), 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))
cases: Sequence[Tuple[str, Callable[[Path], None], str]] = (
(
"exact-count",
lambda root: mutate_remove_line(root / "docs/content/CATALOGS.md", "`AR-060`"),
"REGISTRY_EXACT_SET",
),
(
"duplicate-id",
lambda root: (root / "docs/content/CATALOGS.md").write_text(
(root / "docs/content/CATALOGS.md").read_text(encoding="utf-8")
+ "| `TR-001` | duplicate | duplicate |\n",
encoding="utf-8",
),
"ID_DUPLICATE",
),
(
"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",
),
(
"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",
),
(
"page-coverage",
lambda root: mutate_remove_line(
root / "docs/presentation/SCREEN_AND_STATE_MAP.md", "`PG-012`"
),
"PAGE_CAPABILITY_MISSING",
),
(
"art-coverage",
lambda root: mutate_remove_line(
root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md", "Profession coverage"
),
"ART_ID_COVERAGE_MISSING",
),
(
"audio-coverage",
lambda root: mutate_remove_line(
root / "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md", "`SFX-DISMISSAL`"
),
"AUDIO_CAPABILITY_MISSING",
),
)
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))
)
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())