1855 lines
68 KiB
Python
Executable File
1855 lines
68 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]+)`")
|
|
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"
|
|
|
|
|
|
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 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)
|
|
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_screen_coverage(documents, definitions, issues, counts)
|
|
validate_art_coverage(documents, references, issues)
|
|
validate_audio_coverage(
|
|
documents,
|
|
definitions,
|
|
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 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"
|
|
"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",
|
|
)
|
|
|
|
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"),
|
|
("SYS-001", "Save and cloud", "Manage save", "empty, error, confirmation"),
|
|
("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
|
|
)
|
|
(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"
|
|
"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.
|
|
|
|
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`.
|
|
"""
|
|
(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.",
|
|
"",
|
|
"## 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"
|
|
)
|
|
|
|
|
|
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,
|
|
}
|
|
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))
|
|
|
|
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",
|
|
),
|
|
)
|
|
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())
|