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