From ec5e2347c9d7083de575c2a0dbfe1aded7c9cf29 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 11 Aug 2026 09:52:48 +0800 Subject: [PATCH] fix(validation): gate R3 cross-document contradictions --- docs/reviews/DESIGN_VALIDATION_CONTRACT.md | 114 ++- tools/validate_predevelopment_design.py | 814 ++++++++++++++++++++- 2 files changed, 912 insertions(+), 16 deletions(-) diff --git a/docs/reviews/DESIGN_VALIDATION_CONTRACT.md b/docs/reviews/DESIGN_VALIDATION_CONTRACT.md index de003344..51c93b44 100644 --- a/docs/reviews/DESIGN_VALIDATION_CONTRACT.md +++ b/docs/reviews/DESIGN_VALIDATION_CONTRACT.md @@ -1,10 +1,10 @@ # Pre-development Design Validation Contract -> Contract revision: `abg-design-validation-v2` +> Contract revision: `abg-design-validation-v2-crossdoc-r3` > Frozen input: `.agent-taskgraph/spec.md` revision `design-spec-r3` -> Integrated schema evidence: `b2d8f7b` +> Integrated schema evidence: `999034a` > Executable: `tools/validate_predevelopment_design.py` -> Scope: merged Markdown design authorities only; no runtime or product authority +> Scope: merged Markdown design authorities plus one scoped reference gate; no runtime or product authority ## 1. Purpose And Boundary @@ -13,7 +13,7 @@ UX, art, and audio design package. It detects mechanical omissions before independent review without choosing product values or changing another workstream's schema. -The executable answers five bounded questions: +The executable answers ten bounded questions: 1. Do the primary catalogs contain the exact frozen content envelope with unique, gap-free stable IDs and nonempty rows? @@ -22,6 +22,16 @@ The executable answers five bounded questions: 4. Does an authority positively prescribe a retired redesign concept? 5. Are the required page, art, music, ambience, SFX, and voice registries structurally complete? +6. Does the stable-screen and onboarding presentation preserve the product's + provisional selected/offered values and pre-creation revision boundary? +7. Do UI and art assign the same exposed/protected party endpoints to left and + right? +8. Does every generated-recruit presentation field have an exact, + deterministic content/art crosswalk? +9. Does audio bind the exact primary screen-ID set, including `OUT-006`, and + declare the matching total? +10. Does the scoped reference authority reject rather than prescribe retired + fixed-cast and relationship scope? Passing is necessary but not sufficient. The validator cannot establish originality, balance, comprehension, readability, production feasibility, @@ -41,6 +51,7 @@ These files must exist: - `docs/product/SYSTEMS_AND_BATTLE.md` - `docs/product/ECONOMY_AND_BALANCE.md` - `docs/product/SAVE_AND_FAILURE_CONTRACT.md` +- `docs/content/RECRUIT_GENERATION.md` - `docs/presentation/SCREEN_AND_STATE_MAP.md` - `docs/presentation/UI_AND_VISUAL_SYSTEM.md` - `docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md` @@ -48,7 +59,10 @@ These files must exist: `docs/content/CHARACTER_BIBLE.md` is a retired fixed-character authority and must not exist. Reference research, reviews, prototypes, and taskgraph files -are outside the product-authority scan. +are outside the ordinary authority scan. The sole exception is the targeted +retired-scope scan of `docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md`; its +IDs, ranges, and other research statements do not enter product/content +registry validation. ## 3. Primary Content Registries @@ -217,7 +231,66 @@ casualty, dismissal, reward, failure, accessibility, and generated-recruit voice scope. Underscore event names such as `inspect_open` count as explicit inspection coverage. -## 8. Diagnostics And Exit Contract +## 8. Cross-Document Semantic Gates + +### 8.1 Provisional Opening Choice + +The product authority supplies the selected and offered cardinalities; the +validator does not duplicate them as fallback constants. Every positive +provisional-choice declaration in the product contract must resolve to one +consistent pair. A primary stable-screen row and one row in the onboarding map +must each repeat that pair and state that the choice remains revisable before +Run creation. Pair presence elsewhere in the screen document cannot satisfy +either row-bound requirement. + +### 8.2 Party Direction + +`UI_AND_VISUAL_SYSTEM.md` owns the left/right placement of `First exposed` and +`Last`. `ART_ASSET_AND_ANIMATION_CATALOG.md` must independently declare the +left/right exposed-front and protected-rear endpoints. The validator normalizes +both to `(left endpoint, right endpoint)` and requires exact equality; naming +both concepts without direction is insufficient. + +### 8.3 Recruit Content/Art Crosswalk + +The required generated-recruit fields and option counts come from the fenced +Recruit Record and the Modular Visual Grammar table in +`RECRUIT_GENERATION.md`. Art must provide a Recruit Content/Art Crosswalk table +with these semantic columns: + +| Column | Required value | +|---|---| +| Content field | exact snake-case Recruit Record field | +| Content count/options/values | exact content-owned cardinality | +| Art source/target/library/binding | the declared source library or explicit nonvisual owner | +| Art count/options/masters/values | the same cardinality for visual fields; `N/A` or zero for `voice_effort_set` | +| Mapping/resolution/rule | deterministic, one-to-one, or same-index resolution | + +The ten required rows are `body_frame`, `face_shape`, `skin_palette`, +`hair_shape`, `hair_palette`, `garment_cut`, `garment_palette`, +`profession_overlay`, `detail_mark`, and `voice_effort_set`. Each visual row's +count must also equal the matching source inventory declaration. The voice row +must explicitly identify audio ownership or a nonvisual disposition. A prose +claim that generation is deterministic cannot replace this field-level table. + +### 8.4 Screen/Audio Identity + +Primary screen rows in `SCREEN_AND_STATE_MAP.md` are the canonical set. That +set must include `OUT-006`. The audio dependency row named `Screen and action +IDs` must expand to exactly the same set, with neither omissions nor additions, +and its declared screen total must equal the canonical set size. A matching +count with a different ID set still fails. + +### 8.5 Scoped Reference Retirement + +`TRANSFERABLE_STRUCTURE_LESSONS.md` may discuss fixed-cast or relationship +concepts only as explicit rejection, removal, or historical/reference +evidence. Positive `profession affinities`, `relationship hook/system`, fixed +cast, or an approved exact scope of 30 named recruitable characters fails. +Markdown table cells are evaluated separately so a filler-rejection cell does +not negate a positive requirement in another cell on the same row. + +## 9. Diagnostics And Exit Contract Run from the repository root: @@ -249,8 +322,13 @@ The main diagnostic families are: | `BINDING_*` | declaration missing/duplicated or value unresolved | | `RETIRED_*` | obsolete product authority remains positive | | `PAGE_*`, `ART_*`, `AUDIO_*` | presentation registry or coverage absent | +| `PROVISIONAL_SELECTION_*` | product selected/offered values are missing, inconsistent, or absent from row-bound presentation | +| `PARTY_DIRECTION_MISMATCH` | UI and art disagree on left/right exposed/protected endpoints | +| `RECRUIT_ART_CROSSWALK_MISMATCH` | recruit fields, counts, sources, or deterministic mappings do not agree | +| `AUDIO_SCREEN_INVENTORY_MISMATCH` | audio screen IDs/count do not equal the primary screen registry | +| `REFERENCE_RETIRED_SCOPE` | the scoped reference authority positively prescribes retired cast/relationship scope | -## 9. Self-test And Integrated Evidence +## 10. Self-test And Integrated Evidence The generated pass fixture uses the integrated schemas and includes regression coverage for every false-positive class from Attempt 1: @@ -262,19 +340,25 @@ coverage for every false-positive class from Attempt 1: - abbreviated, suffix, brace, and mixed-prefix ranges - music/ambience tables plus fenced voice/SFX registries - underscore-based inspection events +- a coherent two-of-four provisional flow in both stable-screen and onboarding rows +- matching UI/art party endpoints +- all ten generated-recruit crosswalk rows with compatible source counts +- an exact screen/audio ID set including `OUT-006` +- historical/rejection-only reference mentions of retired scope Negative fixtures cover exact counts, regional gaps, primary duplicates, broken references, unresolved and unknown bindings, unbounded ranges, positive -retired concepts, filler rows, page/art/audio omissions, and audio duplicates. +retired concepts, filler rows, page/art/audio omissions, audio duplicates, and +one isolated mutation for each of the five Experience R3 contradiction classes. The success terminal is: ```text -ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=15 +ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=20 ``` -On integrated revision `b2d8f7b`, all content and production registry counts -resolve exactly. The remaining failures are the bracket bindings declared by -`AUDIO_MUSIC_AND_VOICE_CATALOG.md` as required integration inputs. That is an -authority boundary, not a validator schema alias; the validator must continue -to return nonzero until those bindings are resolved or redesigned by their -owner. +On baseline revision `999034a`, all prior exact content/audio registry checks +remain satisfied while the new gates report the five immutable Experience R3 +contradiction classes: provisional presentation, party direction, recruit +field crosswalk, the missing audio binding for `OUT-006` plus the 49/50 total, +and positive retired reference scope. That nonzero result is expected until the +disjoint authority repairs are supplied and reintegrated by PMO. diff --git a/tools/validate_predevelopment_design.py b/tools/validate_predevelopment_design.py index 92f3c21b..6d516383 100755 --- a/tools/validate_predevelopment_design.py +++ b/tools/validate_predevelopment_design.py @@ -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{})\b" + r"(?=[^\n|.]{{0,120}}\bprovisional\b)" + r"(?=[^\n|.]{{0,120}}\brecruits?\b)" + r"[^\n|.]{{0,100}}\b(?:of|from)\s+(?:the\s+)?(?P{})\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: