fix(validation): gate R3 cross-document contradictions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user