fix(validation): gate R3 cross-document contradictions

This commit is contained in:
2026-08-11 10:04:22 +08:00
parent 96ea49ab2a
commit 8a6f0c8499
2 changed files with 912 additions and 16 deletions
+813 -1
View File
@@ -24,6 +24,7 @@ REQUIRED_FILES = (
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"),
@@ -246,6 +247,105 @@ EQUIPMENT_PATH = "docs/content/EQUIPMENT_CATALOG.md"
ENEMY_PATH = "docs/content/ENEMY_CATALOG.md"
SCREEN_PATH = "docs/presentation/SCREEN_AND_STATE_MAP.md"
AUDIO_PATH = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md"
PRODUCT_PATH = "docs/product/GAME_PRODUCT_CONTRACT.md"
RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.md"
UI_PATH = "docs/presentation/UI_AND_VISUAL_SYSTEM.md"
ART_PATH = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md"
REFERENCE_SCOPE_PATH = "docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md"
PAGE_ID_RE = re.compile(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}")
COUNT_WORDS = {
"zero": 0,
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"twelve": 12,
"sixteen": 16,
"twenty": 20,
"twenty-four": 24,
"thirty-six": 36,
"forty-two": 42,
}
COUNT_TOKEN = r"(?:\d+|" + "|".join(sorted(COUNT_WORDS, key=len, reverse=True)) + r")"
PROVISIONAL_SELECTION_RE = re.compile(
r"\b(?:choose|select(?:ed|s|ing)?)\s+(?:exactly\s+)?(?P<selected>{})\b"
r"(?=[^\n|.]{{0,120}}\bprovisional\b)"
r"(?=[^\n|.]{{0,120}}\brecruits?\b)"
r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P<offered>{})\b".format(
COUNT_TOKEN, COUNT_TOKEN
),
re.IGNORECASE,
)
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"\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),
),
)
def relative_path(path: Path, root: Path) -> str:
@@ -1042,6 +1142,558 @@ def document_by_path(documents: Sequence[Document], path: str) -> Optional[Docum
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):
for match in PROVISIONAL_SELECTION_RE.finditer(line):
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))
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 = any(
expected in {(selected, offered) for selected, offered, _, _ in provisional_selection_pairs(row_text)}
and selection_is_revisable_before_creation(row_text)
for _, row_text in primary_candidates
)
onboarding = section_lines(screen, re.compile(r"\bonboarding\b", re.IGNORECASE))
onboarding_valid = any(
expected
in {(selected, offered) for selected, offered, _, _ in provisional_selection_pairs(line)}
and selection_is_revisable_before_creation(line)
for _, line in onboarding
)
if primary_valid and onboarding_valid:
return
missing: List[str] = []
if not primary_valid:
missing.append("a stable screen row")
if not onboarding_valid:
missing.append("the onboarding map")
issues.append(
issue(
"PROVISIONAL_SELECTION_PRESENTATION_MISMATCH",
SCREEN_PATH,
primary_candidates[0][0].line if primary_candidates else 1,
primary_candidates[0][0].column if primary_candidates else 1,
"{} must present choosing {} of {} provisional recruits as revisable before run creation".format(
" and ".join(missing), expected[0], expected[1]
),
)
)
def endpoint_kind(value: str) -> Optional[str]:
exposed = re.search(r"\b(?:exposed|front)\b", value, re.IGNORECASE) is not None
protected = re.search(r"\b(?:protected|rear|last)\b", value, re.IGNORECASE) is not None
if exposed == protected:
return None
return "exposed" if exposed else "protected"
def extract_ui_party_direction(document: Document) -> Optional[Tuple[str, str, int, int]]:
for line_number, line in enumerate(document.text.splitlines(), 1):
exposed = re.search(r"\b(?:first\s+)?exposed\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
last = re.search(r"\blast\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
if exposed is None or last is None or exposed.group(1).lower() == last.group(1).lower():
continue
sides = {exposed.group(1).lower(): "exposed", last.group(1).lower(): "protected"}
return sides["left"], sides["right"], line_number, exposed.start() + 1
return None
def extract_art_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,
)
for line_number, line in enumerate(document.text.splitlines(), 1):
match = direction_re.search(line)
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, line_number, match.start() + 1
exposed = re.search(r"\b(?:exposed|front)\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
protected = re.search(r"\b(?:protected|rear)\b.{0,40}?\b(left|right)\b", line, re.IGNORECASE)
if exposed is None or protected is None or exposed.group(1).lower() == protected.group(1).lower():
continue
sides = {exposed.group(1).lower(): "exposed", protected.group(1).lower(): "protected"}
return sides["left"], sides["right"], line_number, exposed.start() + 1
return None
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 cell == "field"):
candidate_columns["field"] = 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)\b", cell):
candidate_columns["mapping"] = index
if set(candidate_columns) == {"field", "content_count", "art_source", "art_count", "mapping"}:
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 content field/count, art source/count, and deterministic mapping 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 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_count = count_value(normalize_cell(cells[columns["content_count"]]))
art_count_text = normalize_cell(cells[columns["art_count"]])
art_count = count_value(art_count_text)
source = normalize_cell(cells[columns["art_source"]])
mapping = normalize_cell(cells[columns["mapping"]])
if content_count != expected:
errors.append(
"{} crosswalk content count is {}; expected {}".format(field, content_count, expected)
)
if field == "voice_effort_set":
if not re.search(r"\b(?:audio|nonvisual|no\s+art)\b", source, re.IGNORECASE):
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 source_pattern.search(source):
errors.append("{} maps to an unrecognized art source".format(field))
if art_count != expected:
errors.append("{} art count is {}; expected {}".format(field, art_count, expected))
matching_sources = {
count for label, count in source_counts if source_pattern.search(label)
}
if matching_sources != {expected}:
errors.append(
"{} art source declares {}; expected exactly {}".format(
field,
sorted(matching_sources) if matching_sources else "no count",
expected,
)
)
if not mapping_is_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]],
@@ -1328,6 +1980,7 @@ def validate_audio_coverage(
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] = {}
@@ -1343,6 +1996,9 @@ def audit(root: Path) -> AuditResult:
validate_declared_bindings(documents, issues, counts)
validate_pending_bindings(documents, issues)
validate_retired_concepts(documents, issues)
validate_provisional_selection_presentation(documents, definitions, issues)
validate_party_direction(documents, issues)
validate_recruit_art_crosswalk(documents, issues)
validate_screen_coverage(documents, definitions, issues, counts)
validate_art_coverage(documents, references, issues)
validate_audio_coverage(
@@ -1351,6 +2007,8 @@ def audit(root: Path) -> AuditResult:
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())))
@@ -1361,7 +2019,10 @@ def write_fixture(root: Path) -> None:
(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",
"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"
@@ -1459,6 +2120,33 @@ def write_fixture(root: Path) -> None:
"# Content Index\n\n```sh\n! rg -ni 'relationship|romance|six-unit|Directive|Anchor|Cache|three-lane' docs/content\n```\n",
encoding="utf-8",
)
recruit = """# Generated Recruit Authority
## 1. Recruit Record
```text
recruit_instance_id, generation_seed, offer_index
display_name_tokens, pronoun_set, voice_effort_set
body_frame, face_shape, skin_palette, hair_shape, hair_palette
garment_cut, garment_palette, profession_overlay, detail_mark
```
## 4. Modular Visual Grammar
| Part | Options | Rule |
|---|---:|---|
| Body frame | 8 | deterministic option |
| Face shape | 12 | deterministic option |
| Skin palette | 12 | deterministic option |
| Hair/head shape | 24 | deterministic option |
| Hair palette | 10 | deterministic option |
| Garment cut | 12 | deterministic option |
| Garment palette | 16 | deterministic option |
| Profession overlay | 42 | deterministic option |
| Detail mark | 20 | deterministic option |
| Voice effort set | 8 | audio-owned option |
"""
(root / RECRUIT_PATH).write_text(recruit, encoding="utf-8")
page_rows = (
("SHP-001", "Shop offers", "Buy recruit", "empty, error, confirmation"),
@@ -1468,7 +2156,14 @@ def write_fixture(root: Path) -> None:
("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"),
@@ -1490,11 +2185,22 @@ def write_fixture(root: Path) -> None:
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",
@@ -1505,6 +2211,7 @@ def write_fixture(root: Path) -> None:
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`.
@@ -1512,6 +2219,37 @@ Equipment coverage: `ART-EQP-001..320`.
Artifact coverage: `ART-AFT-01..60`.
Enemy coverage: `ART-ENM-001..100`; boss coverage: `ART-BOS-01..16`.
Region coverage: `ART-REG-01..08`.
## 4. Generated Recruit Visual System
### 4.2 Modular Source Inventory
| Library | Exact masters | Runtime states |
|---|---:|---:|
| Body foundations | 8 | 1 |
| Face structures | 12 | 1 |
| Skin ramps | 12 | 1 |
| Hair families | 24 | 1 |
| Hair ramps | 10 | 1 |
| Underlayers | 12 | 1 |
| Underlayer ramps | 16 | 1 |
| Profession kits | 42 | 1 |
| Face detail sets | 20 | 1 |
### 4.3 Recruit Content/Art Crosswalk
| Content field | Content options | Art source | Art masters | Mapping rule |
|---|---:|---|---:|---|
| `body_frame` | 8 | Body foundations | 8 | same stable index |
| `face_shape` | 12 | Face structures | 12 | same stable index |
| `skin_palette` | 12 | Skin ramps | 12 | same stable index |
| `hair_shape` | 24 | Hair families | 24 | same stable index |
| `hair_palette` | 10 | Hair ramps | 10 | same stable index |
| `garment_cut` | 12 | Underlayers | 12 | same stable index |
| `garment_palette` | 16 | Underlayer ramps | 16 | same stable index |
| `profession_overlay` | 42 | Profession kits | 42 | same stable index |
| `detail_mark` | 20 | Face detail sets | 20 | same stable index |
| `voice_effort_set` | 8 | Audio-owned nonvisual field | N/A | audio-owned deterministic field value |
"""
(root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8")
@@ -1564,6 +2302,10 @@ Region coverage: `ART-REG-01..08`.
"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",
@@ -1609,6 +2351,13 @@ Region coverage: `ART-REG-01..08`.
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 mutate_remove_line(path: Path, needle: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
@@ -1645,6 +2394,8 @@ def run_self_test() -> None:
"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:
@@ -1783,6 +2534,67 @@ def run_self_test() -> None:
),
"ID_DUPLICATE",
),
(
"provisional-selection-presentation",
lambda root: (root / SCREEN_PATH).write_text(
(root / SCREEN_PATH)
.read_text(encoding="utf-8")
.replace(
"Choose two provisional Recruits from four generated offers",
"Choose one provisional Recruit from four generated offers",
1,
),
encoding="utf-8",
),
"PROVISIONAL_SELECTION_PRESENTATION_MISMATCH",
),
(
"party-direction",
lambda root: (root / ART_PATH).write_text(
(root / ART_PATH)
.read_text(encoding="utf-8")
.replace(
"left-to-right from exposed front to protected rear",
"left-to-right from protected rear to exposed front",
1,
),
encoding="utf-8",
),
"PARTY_DIRECTION_MISMATCH",
),
(
"recruit-art-crosswalk",
lambda root: (root / ART_PATH).write_text(
(root / ART_PATH)
.read_text(encoding="utf-8")
.replace(
"| `body_frame` | 8 | Body foundations | 8 |",
"| `body_frame` | 8 | Body foundations | 12 |",
1,
),
encoding="utf-8",
),
"RECRUIT_ART_CROSSWALK_MISMATCH",
),
(
"audio-screen-inventory",
lambda root: (root / AUDIO_PATH).write_text(
(root / AUDIO_PATH)
.read_text(encoding="utf-8")
.replace("`OUT-006`, ", "", 1),
encoding="utf-8",
),
"AUDIO_SCREEN_INVENTORY_MISMATCH",
),
(
"reference-retired-scope",
lambda root: (root / REFERENCE_SCOPE_PATH).write_text(
(root / REFERENCE_SCOPE_PATH).read_text(encoding="utf-8")
+ "Approved scope: 30 named recruitable characters require profession affinities and relationship hooks.\n",
encoding="utf-8",
),
"REFERENCE_RETIRED_SCOPE",
),
)
for name, mutate, expected_code in cases:
with tempfile.TemporaryDirectory(prefix="abg-design-validator-{}-".format(name)) as temporary: