validator: add R4 semantic regression gates
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Pre-development Design Validation Contract
|
||||
|
||||
> Contract revision: `abg-design-validation-v2-crossdoc-r3-h1`
|
||||
> Contract revision: `abg-design-validation-v2-crossdoc-r4-h2`
|
||||
> Frozen input: `.agent-taskgraph/spec.md` revision `design-spec-r3`
|
||||
> Integrated schema evidence: `b65646c`
|
||||
> Integrated schema evidence: `8102bd2`
|
||||
> Executable: `tools/validate_predevelopment_design.py`
|
||||
> Scope: merged Markdown design authorities plus one scoped reference gate; no runtime or product authority
|
||||
|
||||
@@ -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 ten bounded questions:
|
||||
The executable answers twelve bounded questions:
|
||||
|
||||
1. Do the primary catalogs contain the exact frozen content envelope with
|
||||
unique, gap-free stable IDs and nonempty rows?
|
||||
@@ -32,6 +32,12 @@ The executable answers ten bounded questions:
|
||||
declare the matching total?
|
||||
10. Does the scoped reference authority reject rather than prescribe retired
|
||||
fixed-cast and relationship scope?
|
||||
11. Is one Run seed durably reserved and read back before opening generation,
|
||||
with all four offers, a revisable pair, and atomic exactly-once promotion
|
||||
bound to that one draft?
|
||||
12. Does every hidden-profession art row bind the exact content-owned identity,
|
||||
parents, mechanic, silhouette/prop brief, VFX cue, and action language
|
||||
without a deferred content pointer?
|
||||
|
||||
Passing is necessary but not sufficient. The validator cannot establish
|
||||
originality, balance, comprehension, readability, production feasibility,
|
||||
@@ -296,6 +302,58 @@ 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.
|
||||
|
||||
### 8.6 Seed-First Run-Creation Transaction
|
||||
|
||||
`SAVE_AND_FAILURE_CONTRACT.md` owns the durable transaction. Its seed-first
|
||||
section must place an applied, persisted or read-back Run seed/draft boundary
|
||||
before opening generation. `RECRUIT_GENERATION.md` must independently restrict
|
||||
opening generation to that validated read-back draft created by seed
|
||||
reservation. A seed that exists only inside `current_run`, or one persisted
|
||||
after generation, fails `RUN_CREATION_SEED_ORDER_MISMATCH`.
|
||||
|
||||
The save schema must declare `run_creation_draft` with one seed, the exact
|
||||
four-offer set, the exact two-offer selection and its revision, mutually
|
||||
exclusive with `current_run`. The transaction table must semantically bind four
|
||||
operations: reserve a seed-only draft, materialize all four indexed offer
|
||||
records/IDs/hashes, persist a revisable exact pair over those same four, and
|
||||
promote the same seed and selected records while removing the draft. The
|
||||
detailed flow must also establish one-commit offer materialization, revision
|
||||
without regeneration, atomic promotion, and retry resolution to the original
|
||||
applied receipt without a second Run or second transfer.
|
||||
|
||||
These checks accept equivalent wording such as persisted/durable, read-back,
|
||||
all-or-nothing/atomic, original/one applied receipt, and no/cannot create a
|
||||
second Run. They do not match a repaired sentence byte-for-byte. Missing schema,
|
||||
table, revision, materialization, promotion, or retry evidence fails
|
||||
`RUN_CREATION_TRANSACTION_INCOMPLETE`.
|
||||
|
||||
### 8.7 Hidden-Profession Content/Art Binding
|
||||
|
||||
`PROFESSION_CATALOG.md` owns `PF-H01..PF-H06`, each name, exact two-base parent
|
||||
set, core mechanic and comma-delimited production brief. The validator derives
|
||||
those values from the Hidden Hybrid Professions table; it does not keep a
|
||||
second hard-coded parent or mechanic map.
|
||||
|
||||
The matching Hidden Hybrid Profession Kits table in
|
||||
`ART_ASSET_AND_ANIMATION_CATALOG.md` must provide, for every ID:
|
||||
|
||||
- the exact `ART-PRO-Hxx`, `CONTENT.PROFESSION.HIDDEN[xx]`, `PF-Hxx` and name;
|
||||
- the same two `PF-Bxx` parents as content;
|
||||
- resolved silhouette translation and held/worn prop or anchor fields;
|
||||
- resolved VFX/event-cue and animation/action-verb fields;
|
||||
- semantic overlap between the content mechanic and art translation, and
|
||||
between each content production channel and its art field.
|
||||
|
||||
Comparison removes Markdown emphasis, case, plural and common inflection
|
||||
differences, then ignores generic connective vocabulary. This permits
|
||||
independently written production language while still rejecting an unrelated
|
||||
bow/vial, hammer/lantern, or other mechanic package. Missing identities,
|
||||
parents, fields, or mechanic-compatible channel bindings fail
|
||||
`HIDDEN_PROFESSION_ART_MISMATCH`. Deferred forms such as
|
||||
`CONTENT.PROFESSION.HIDDEN[xx].parents`, unresolved, placeholder, pending, or
|
||||
unknown fail `HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED` and are not also
|
||||
reported as a parent mismatch for the same row.
|
||||
|
||||
## 9. Diagnostics And Exit Contract
|
||||
|
||||
Run from the repository root:
|
||||
@@ -333,6 +391,10 @@ The main diagnostic families are:
|
||||
| `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 |
|
||||
| `RUN_CREATION_SEED_ORDER_MISMATCH` | durable seed/read-back does not precede and authorize opening generation |
|
||||
| `RUN_CREATION_TRANSACTION_INCOMPLETE` | exact-four materialization, revisable pair, atomic promotion, or exactly-once retry evidence is incomplete |
|
||||
| `HIDDEN_PROFESSION_ART_MISMATCH` | hidden art identity, parents, mechanic, silhouette/props, VFX, or actions disagree with content |
|
||||
| `HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED` | hidden art delegates a required row binding to unresolved/pending language |
|
||||
|
||||
## 10. Self-test And Integrated Evidence
|
||||
|
||||
@@ -351,6 +413,11 @@ coverage for every false-positive class from Attempt 1:
|
||||
- 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
|
||||
- a durable seed-only draft read back before opening generation, followed by
|
||||
exact-four materialization, revisable exact-pair selection, and atomic
|
||||
exactly-once Run promotion
|
||||
- six content-owned hidden-profession parent/mechanic/production briefs with
|
||||
exact resolved silhouette, prop, VFX, and action bindings
|
||||
- an alternate integrated-shape pass using staged `SYS-005` onboarding,
|
||||
side-first endpoint clauses, embedded stored-authority counts, and bounded
|
||||
art-binding ranges
|
||||
@@ -359,17 +426,21 @@ 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, audio duplicates, and
|
||||
one isolated mutation for each of the five Experience R3 contradiction classes.
|
||||
Those five mutations run after rewriting the fixture into the alternate
|
||||
integrated shape, so flexible positive parsing does not weaken the negative
|
||||
gates. The success terminal remains:
|
||||
Four additional isolated R4 mutations invert seed/generation order, remove the
|
||||
atomic promotion proof, substitute a mechanic-incompatible hidden-art package,
|
||||
and restore a deferred hidden-parent pointer. The five R3 mutations run after
|
||||
rewriting the fixture into the alternate integrated shape, while all four R4
|
||||
mutations run against the same positive semantic fixture. Flexible positive
|
||||
parsing therefore cannot weaken either negative boundary. The success terminal
|
||||
is:
|
||||
|
||||
```text
|
||||
ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=20
|
||||
ABG_PREDEVELOPMENT_DESIGN_SELF_TEST_OK cases=24
|
||||
```
|
||||
|
||||
On integrated revision `b65646c`, all prior exact content/audio registry checks
|
||||
remain satisfied and the repaired real documents resolve with zero diagnostics
|
||||
under the H1 semantic parsers. The self-test still proves all five immutable
|
||||
Experience R3 contradiction classes fail when independently mutated:
|
||||
provisional presentation, party direction, recruit field crosswalk,
|
||||
`OUT-006`/screen total, and positive retired reference scope.
|
||||
On integrated product revision `8102bd2`, all prior exact content/audio registry
|
||||
checks remain satisfied and the repaired real documents resolve with zero
|
||||
diagnostics. The self-test still proves all five immutable Experience R3
|
||||
contradiction classes fail independently, and now also proves both R4 failure
|
||||
boundaries in two ways each: seed order versus durable transaction completeness,
|
||||
and hidden mechanic mismatch versus unresolved art binding.
|
||||
|
||||
@@ -180,6 +180,28 @@ class RegistryRule:
|
||||
heading_pattern: Optional[re.Pattern] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HiddenProfessionContent:
|
||||
identifier: str
|
||||
name: str
|
||||
parents: Tuple[str, ...]
|
||||
mechanic: str
|
||||
production_segments: Tuple[str, ...]
|
||||
line: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HiddenProfessionArt:
|
||||
identifier: str
|
||||
identity: str
|
||||
parents: Tuple[str, ...]
|
||||
translation: str
|
||||
props: str
|
||||
vfx: str
|
||||
actions: str
|
||||
line: int
|
||||
|
||||
|
||||
def numbered_ids(prefix: str, count: int, width: int) -> Tuple[str, ...]:
|
||||
return tuple("{}{:0{}d}".format(prefix, number, width) for number in range(1, count + 1))
|
||||
|
||||
@@ -248,11 +270,75 @@ ENEMY_PATH = "docs/content/ENEMY_CATALOG.md"
|
||||
SCREEN_PATH = "docs/presentation/SCREEN_AND_STATE_MAP.md"
|
||||
AUDIO_PATH = "docs/presentation/AUDIO_MUSIC_AND_VOICE_CATALOG.md"
|
||||
PRODUCT_PATH = "docs/product/GAME_PRODUCT_CONTRACT.md"
|
||||
SAVE_PATH = "docs/product/SAVE_AND_FAILURE_CONTRACT.md"
|
||||
RECRUIT_PATH = "docs/content/RECRUIT_GENERATION.md"
|
||||
PROFESSION_PATH = "docs/content/PROFESSION_CATALOG.md"
|
||||
UI_PATH = "docs/presentation/UI_AND_VISUAL_SYSTEM.md"
|
||||
ART_PATH = "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md"
|
||||
REFERENCE_SCOPE_PATH = "docs/reference/TRANSFERABLE_STRUCTURE_LESSONS.md"
|
||||
PAGE_ID_RE = re.compile(r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3}")
|
||||
HIDDEN_PROFESSION_IDS = numbered_ids("PF-H", 6, 2)
|
||||
HIDDEN_ART_UNRESOLVED_RE = re.compile(
|
||||
r"\b(?:unresolved|placeholder|pending|unknown|to\s+be\s+(?:resolved|bound))\b|"
|
||||
r"CONTENT\.PROFESSION\.HIDDEN\[\d{2}\]\.parents\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SEMANTIC_STOP_WORDS = frozenset(
|
||||
{
|
||||
"a",
|
||||
"action",
|
||||
"an",
|
||||
"and",
|
||||
"art",
|
||||
"as",
|
||||
"at",
|
||||
"be",
|
||||
"both",
|
||||
"by",
|
||||
"content",
|
||||
"cue",
|
||||
"each",
|
||||
"event",
|
||||
"exact",
|
||||
"field",
|
||||
"for",
|
||||
"front",
|
||||
"from",
|
||||
"hand",
|
||||
"in",
|
||||
"into",
|
||||
"is",
|
||||
"it",
|
||||
"its",
|
||||
"mechanic",
|
||||
"new",
|
||||
"of",
|
||||
"on",
|
||||
"one",
|
||||
"only",
|
||||
"or",
|
||||
"owned",
|
||||
"pair",
|
||||
"profession",
|
||||
"rear",
|
||||
"role",
|
||||
"same",
|
||||
"that",
|
||||
"target",
|
||||
"the",
|
||||
"their",
|
||||
"then",
|
||||
"this",
|
||||
"three",
|
||||
"to",
|
||||
"toward",
|
||||
"two",
|
||||
"use",
|
||||
"uses",
|
||||
"when",
|
||||
"with",
|
||||
}
|
||||
)
|
||||
|
||||
COUNT_WORDS = {
|
||||
"zero": 0,
|
||||
@@ -1394,6 +1480,542 @@ def validate_provisional_selection_presentation(
|
||||
)
|
||||
|
||||
|
||||
def semantic_plain_text(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", re.sub(r"[`*]", "", value)).strip().lower()
|
||||
|
||||
|
||||
def semantic_has_groups(value: str, groups: Sequence[Sequence[str]]) -> bool:
|
||||
plain = semantic_plain_text(value)
|
||||
return all(any(re.search(pattern, plain, re.IGNORECASE) for pattern in group) for group in groups)
|
||||
|
||||
|
||||
def find_action_row(
|
||||
document: Document, action_pattern: re.Pattern
|
||||
) -> Optional[Tuple[Tuple[str, ...], int]]:
|
||||
for line_number, line in enumerate(document.text.splitlines(), 1):
|
||||
cells = split_markdown_row(line)
|
||||
if cells is None or len(cells) < 3:
|
||||
continue
|
||||
if action_pattern.search(normalize_cell(cells[0])):
|
||||
return cells, line_number
|
||||
return None
|
||||
|
||||
|
||||
def row_cell_has_groups(
|
||||
row: Optional[Tuple[Tuple[str, ...], int]],
|
||||
cell_index: int,
|
||||
groups: Sequence[Sequence[str]],
|
||||
) -> bool:
|
||||
return row is not None and len(row[0]) > cell_index and semantic_has_groups(
|
||||
row[0][cell_index], groups
|
||||
)
|
||||
|
||||
|
||||
def seed_precedes_opening_generation(value: str) -> bool:
|
||||
plain = semantic_plain_text(value)
|
||||
return re.search(
|
||||
r"(?:run_seed|run seed|seed_reserved|seed).{0,360}"
|
||||
r"(?:reserv(?:e|ed|ation)|durab(?:le|ly)|persist(?:ed|ence)?|"
|
||||
r"flush(?:ed)?|read[ -]?back).{0,360}"
|
||||
r"\b(?:before|prior to)\b.{0,160}"
|
||||
r"(?:opening (?:offer )?generat(?:or|ion)|opening generator|"
|
||||
r"generat(?:e|ed|ing|ion) (?:the )?(?:four )?opening)",
|
||||
plain,
|
||||
) is not None
|
||||
|
||||
|
||||
def validate_run_creation_transaction(
|
||||
documents: Sequence[Document], issues: List[Issue]
|
||||
) -> None:
|
||||
save = document_by_path(documents, SAVE_PATH)
|
||||
recruit = document_by_path(documents, RECRUIT_PATH)
|
||||
if save is None or recruit is None:
|
||||
return
|
||||
|
||||
seed_lines = section_lines(
|
||||
save,
|
||||
re.compile(
|
||||
r"(?:\bseed[- ]first\b.*\brun\s+creation\b|"
|
||||
r"\brun\s+creation\b.*\bseed(?:[- ]first|\s+(?:order|reservation|boundary))\b)",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
opening_lines = section_lines(
|
||||
recruit,
|
||||
re.compile(r"\bopening\b.*\b(?:offer|generation)\b|\b(?:offer|generation)\b.*\bopening\b", re.IGNORECASE),
|
||||
)
|
||||
seed_text = "\n".join(line for _, line in seed_lines)
|
||||
opening_text = "\n".join(line for _, line in opening_lines)
|
||||
seed_errors: List[str] = []
|
||||
if not seed_lines:
|
||||
seed_errors.append("seed-first Run-creation section is missing")
|
||||
elif not seed_precedes_opening_generation(seed_text):
|
||||
seed_errors.append("save authority does not place durable seed read-back before opening generation")
|
||||
if not opening_lines or not semantic_has_groups(
|
||||
opening_text,
|
||||
(
|
||||
(r"\bopening generation\b", r"\bopening generator\b"),
|
||||
(r"\brun_creation_draft\b", r"\brun creation draft\b"),
|
||||
(r"\bread[ -]?back\b",),
|
||||
(r"\bseed[- ]reservation\b", r"\breserved seed\b", r"\bseed_reserved\b"),
|
||||
(r"\blegal only\b", r"\bonly from\b", r"\bcannot\b.*\bbefore\b"),
|
||||
),
|
||||
):
|
||||
seed_errors.append("Recruit generation is not bound to a read-back seed-reservation draft")
|
||||
if seed_errors:
|
||||
issues.append(
|
||||
issue(
|
||||
"RUN_CREATION_SEED_ORDER_MISMATCH",
|
||||
SAVE_PATH,
|
||||
seed_lines[0][0] if seed_lines else 1,
|
||||
1,
|
||||
"seed/order authority is incomplete or contradictory: {}".format(
|
||||
"; ".join(sorted(set(seed_errors)))
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
transaction_errors: List[str] = []
|
||||
if not semantic_has_groups(
|
||||
save.text,
|
||||
(
|
||||
(r"\brun_creation_draft\b",),
|
||||
(r"\brun_seed\b",),
|
||||
(r"\bopening_offers\s*\[\s*0\.\.3\s*\]",),
|
||||
(r"\bselected_offer_ids\s*\[\s*2\s*\]",),
|
||||
(r"\bselection_revision\b",),
|
||||
(r"\bmutually exclusive\b",),
|
||||
(r"\bcurrent_run\b",),
|
||||
),
|
||||
):
|
||||
transaction_errors.append(
|
||||
"durable draft schema lacks seed, exact-four, exact-pair, revision, or exclusivity state"
|
||||
)
|
||||
|
||||
begin_row = find_action_row(
|
||||
save,
|
||||
re.compile(
|
||||
r"^(?:begin|start|initialize|reserve)\b.*\b(?:run\s+creation|creation\s+draft)\b$",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
materialize_row = find_action_row(
|
||||
save,
|
||||
re.compile(
|
||||
r"^(?:materialize|persist|store)\b.*\bopening\b.*\boffers?\b$",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
selection_row = find_action_row(
|
||||
save,
|
||||
re.compile(
|
||||
r"^(?:set|save|persist|revise|accept)\b.*\b(?:opening\s+selection|selected\s+pair)\b$",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
commit_row = find_action_row(
|
||||
save,
|
||||
re.compile(
|
||||
r"^(?:commit|promote|finalize)\b.*\b(?:run\s+creation|creation\s+draft)\b$",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
|
||||
if not row_cell_has_groups(
|
||||
begin_row,
|
||||
2,
|
||||
(
|
||||
(r"\bdraft\b", r"\brun_creation_draft\b"),
|
||||
(r"\bseed_reserved\b", r"\breserved seed\b"),
|
||||
(r"\bno offers\b", r"\bempty offer"),
|
||||
(
|
||||
r"\bno current_run\b",
|
||||
r"\bno offers or current_run\b",
|
||||
r"\bcurrent_run\b.*\bnull\b",
|
||||
),
|
||||
),
|
||||
):
|
||||
transaction_errors.append("Begin run creation does not commit one seed-only draft")
|
||||
if not (
|
||||
row_cell_has_groups(materialize_row, 1, ((r"\bdraft\b",), (r"\bseed\b",)))
|
||||
and row_cell_has_groups(
|
||||
materialize_row,
|
||||
2,
|
||||
(
|
||||
(r"\ball four\b", r"\bfour\b.*\boffer", r"\b0\.\.3\b"),
|
||||
(r"\boffer(?: records?| ids?)\b",),
|
||||
(r"\boutput hashes?\b",),
|
||||
(r"\boffer[- ]set hash\b",),
|
||||
),
|
||||
)
|
||||
):
|
||||
transaction_errors.append("opening materialization does not bind one draft to all four offers and hashes")
|
||||
if not (
|
||||
row_cell_has_groups(
|
||||
selection_row,
|
||||
1,
|
||||
(
|
||||
(r"\bdraft\b",),
|
||||
(r"\bexactly two\b", r"\btwo distinct\b"),
|
||||
(r"\boffer ids?\b",),
|
||||
),
|
||||
)
|
||||
and row_cell_has_groups(
|
||||
selection_row,
|
||||
2,
|
||||
(
|
||||
(r"\bsame four\b",),
|
||||
(r"\bpair\b",),
|
||||
(r"\brevision\b",),
|
||||
(r"\breceipt\b",),
|
||||
),
|
||||
)
|
||||
):
|
||||
transaction_errors.append("selection transaction is not an exact, durable, revisable pair over the saved four")
|
||||
if not (
|
||||
row_cell_has_groups(
|
||||
commit_row,
|
||||
1,
|
||||
((r"\bdraft\b",), (r"\bselection revision\b",), (r"\bpair\b",)),
|
||||
)
|
||||
and row_cell_has_groups(
|
||||
commit_row,
|
||||
2,
|
||||
(
|
||||
(r"\bdraft removed\b", r"\bremove(?:s|d)? the draft\b"),
|
||||
(r"\bsame\b.*\bseed\b",),
|
||||
(r"\bselected recruits?\b", r"\bselected recruit records?\b"),
|
||||
(r"\bcurrent_run\b",),
|
||||
(r"\breceipt\b",),
|
||||
),
|
||||
)
|
||||
):
|
||||
transaction_errors.append("Run promotion row does not transfer the same draft seed/pair into current_run")
|
||||
|
||||
if not semantic_has_groups(
|
||||
opening_text,
|
||||
(
|
||||
(r"\ball four\b",),
|
||||
(r"\bone materialization commit\b", r"\bone commit\b"),
|
||||
(r"\boffer ids?\b",),
|
||||
(r"\boutput hashes?\b", r"\bfour hashes\b"),
|
||||
),
|
||||
):
|
||||
transaction_errors.append("Recruit authority lacks one-commit materialization of the saved four")
|
||||
if not semantic_has_groups(
|
||||
seed_text,
|
||||
(
|
||||
(r"\brevis(?:e|es|ed|ing)\b", r"\bdifferent legal pair\b"),
|
||||
(r"\bselection_revision\b", r"\bselection revision\b"),
|
||||
(r"\buntil run commit\b", r"\bbefore run commit\b"),
|
||||
(r"\bwithout regenerating\b", r"\bno generator call\b", r"\bnever regenerat"),
|
||||
),
|
||||
):
|
||||
transaction_errors.append("accepted selection is not durably revisable without offer regeneration")
|
||||
if not semantic_has_groups(
|
||||
seed_text,
|
||||
(
|
||||
(
|
||||
r"\batomic\b",
|
||||
r"\ball[- ]or[- ]nothing\b",
|
||||
r"\bindivisible\b",
|
||||
r"\b(?:one|single)\s+(?:committed|applied)\s+state\b",
|
||||
),
|
||||
(r"\bremove(?:s|d)? the draft\b", r"\bdraft removed\b"),
|
||||
(r"\bcurrent_run\b",),
|
||||
(r"\bsame\b.{0,80}\brun_seed\b", r"\bsame seed\b"),
|
||||
(r"\bselected\b.{0,40}\brecruit records?\b", r"\btwo unchanged recruit records?\b"),
|
||||
(r"\boriginal receipt\b", r"\bone applied receipt\b"),
|
||||
(
|
||||
r"\bno second run\b",
|
||||
r"\bcannot\b.{0,40}\b(?:create|start)\b.{0,20}\bsecond run\b",
|
||||
r"\bneither\b.{0,100}\bsecond run\b",
|
||||
r"\bnever recreate\b",
|
||||
r"\btransfer twice\b",
|
||||
),
|
||||
),
|
||||
):
|
||||
transaction_errors.append("promotion is not proven atomic and exactly-once under retry")
|
||||
|
||||
if transaction_errors:
|
||||
issues.append(
|
||||
issue(
|
||||
"RUN_CREATION_TRANSACTION_INCOMPLETE",
|
||||
SAVE_PATH,
|
||||
seed_lines[0][0] if seed_lines else 1,
|
||||
1,
|
||||
"durable Run-creation transaction is incomplete: {}".format(
|
||||
"; ".join(sorted(set(transaction_errors)))
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def semantic_term(value: str) -> str:
|
||||
term = value.lower()
|
||||
if term.endswith("ies") and len(term) > 4:
|
||||
return term[:-3] + "y"
|
||||
if term.endswith("ing") and len(term) > 5:
|
||||
term = term[:-3]
|
||||
if len(term) > 3 and term[-1] == term[-2]:
|
||||
term = term[:-1]
|
||||
return term
|
||||
if term.endswith("ed") and len(term) > 4:
|
||||
return term[:-2]
|
||||
if term.endswith(("ches", "shes", "xes", "zes")) and len(term) > 5:
|
||||
return term[:-2]
|
||||
if term.endswith("s") and not term.endswith("ss") and len(term) > 4:
|
||||
return term[:-1]
|
||||
return term
|
||||
|
||||
|
||||
def semantic_terms(value: str) -> Set[str]:
|
||||
terms = {semantic_term(word) for word in re.findall(r"[a-z]+", value.lower())}
|
||||
return {term for term in terms if len(term) >= 3 and term not in SEMANTIC_STOP_WORDS}
|
||||
|
||||
|
||||
def hidden_content_bindings(
|
||||
document: Document,
|
||||
) -> Tuple[Dict[str, HiddenProfessionContent], List[str]]:
|
||||
lines = section_lines(
|
||||
document,
|
||||
re.compile(r"\bhidden\b.*\b(?:hybrid\s+)?professions\b", re.IGNORECASE),
|
||||
)
|
||||
production_column: Optional[int] = None
|
||||
mechanic_columns: Tuple[int, ...] = ()
|
||||
rows: Dict[str, HiddenProfessionContent] = {}
|
||||
errors: List[str] = []
|
||||
for line_number, line in lines:
|
||||
cells = split_markdown_row(line)
|
||||
if cells is None:
|
||||
continue
|
||||
normalized = tuple(normalize_cell(cell) for cell in cells)
|
||||
if production_column is None and any(cell == "id" for cell in normalized):
|
||||
production_column = next(
|
||||
(index for index, cell in enumerate(normalized) if "production" in cell), None
|
||||
)
|
||||
mechanic_columns = tuple(
|
||||
index
|
||||
for index, cell in enumerate(normalized)
|
||||
if "core rule" in cell or "target/reach" in cell
|
||||
)
|
||||
continue
|
||||
match = re.search(r"\b(PF-H\d{2})\b", normalize_cell(cells[0]), re.IGNORECASE)
|
||||
if match is None:
|
||||
continue
|
||||
identifier = match.group(1).upper()
|
||||
if identifier in rows:
|
||||
errors.append("{} is defined more than once in the hidden content table".format(identifier))
|
||||
continue
|
||||
if production_column is None or production_column >= len(cells) or len(cells) < 2:
|
||||
errors.append("hidden content table lacks identity, mechanic, or production columns")
|
||||
continue
|
||||
identity_parts = [part.strip() for part in normalize_cell(cells[1]).split(";")]
|
||||
parents = tuple(sorted(set(re.findall(r"\bPF-B\d{2}\b", cells[1]))))
|
||||
production = cells[production_column]
|
||||
brief = production.split(":", 1)[1] if ":" in production else production
|
||||
segments = tuple(part.strip() for part in brief.split(",") if part.strip())
|
||||
mechanic = " ".join(
|
||||
cells[index] for index in mechanic_columns if index < len(cells)
|
||||
)
|
||||
rows[identifier] = HiddenProfessionContent(
|
||||
identifier,
|
||||
identity_parts[0] if identity_parts else "",
|
||||
parents,
|
||||
mechanic,
|
||||
segments,
|
||||
line_number,
|
||||
)
|
||||
return rows, errors
|
||||
|
||||
|
||||
def hidden_art_bindings(
|
||||
document: Document,
|
||||
) -> Tuple[Dict[str, HiddenProfessionArt], List[str]]:
|
||||
lines = section_lines(
|
||||
document,
|
||||
re.compile(
|
||||
r"\bhidden\b.*\bprofession\b.*\b(?:kits?|bindings?|art)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
)
|
||||
columns: Dict[str, int] = {}
|
||||
rows: Dict[str, HiddenProfessionArt] = {}
|
||||
errors: List[str] = []
|
||||
for line_number, line in lines:
|
||||
cells = split_markdown_row(line)
|
||||
if cells is None:
|
||||
continue
|
||||
normalized = tuple(normalize_cell(cell) for cell in cells)
|
||||
if not columns and any("art id" in cell for cell in normalized):
|
||||
for index, cell in enumerate(normalized):
|
||||
if "art id" in cell:
|
||||
columns["identity"] = index
|
||||
elif "parent" in cell:
|
||||
columns["parents"] = index
|
||||
elif "silhouette" in cell or "translation" in cell:
|
||||
columns["translation"] = index
|
||||
elif "prop" in cell or "anchor" in cell or "equipment" in cell:
|
||||
columns["props"] = index
|
||||
elif "vfx" in cell or "event cue" in cell:
|
||||
columns["vfx"] = index
|
||||
elif "animation" in cell or "action verb" in cell or "motion" in cell:
|
||||
columns["actions"] = index
|
||||
continue
|
||||
identity_index = columns.get("identity", 0)
|
||||
if identity_index >= len(cells):
|
||||
continue
|
||||
match = re.search(
|
||||
r"\bPF-H\d{2}\b", normalize_cell(cells[identity_index]), re.IGNORECASE
|
||||
)
|
||||
if match is None:
|
||||
continue
|
||||
identifier = match.group(0).upper()
|
||||
if identifier in rows:
|
||||
errors.append("{} appears more than once in the hidden-art table".format(identifier))
|
||||
continue
|
||||
required = {"identity", "parents", "translation", "props", "vfx", "actions"}
|
||||
if not required <= set(columns) or max(columns.values()) >= len(cells):
|
||||
errors.append("hidden-art table lacks identity, parent, silhouette, prop, VFX, or action columns")
|
||||
continue
|
||||
rows[identifier] = HiddenProfessionArt(
|
||||
identifier,
|
||||
cells[columns["identity"]],
|
||||
tuple(sorted(set(re.findall(r"\bPF-B\d{2}\b", cells[columns["parents"]])))),
|
||||
cells[columns["translation"]],
|
||||
cells[columns["props"]],
|
||||
cells[columns["vfx"]],
|
||||
cells[columns["actions"]],
|
||||
line_number,
|
||||
)
|
||||
return rows, errors
|
||||
|
||||
|
||||
def validate_hidden_profession_art(
|
||||
documents: Sequence[Document], issues: List[Issue]
|
||||
) -> None:
|
||||
content_document = document_by_path(documents, PROFESSION_PATH)
|
||||
art_document = document_by_path(documents, ART_PATH)
|
||||
if content_document is None or art_document is None:
|
||||
return
|
||||
content_rows, content_errors = hidden_content_bindings(content_document)
|
||||
art_rows, art_errors = hidden_art_bindings(art_document)
|
||||
mismatch_errors = list(content_errors) + list(art_errors)
|
||||
|
||||
missing_content = sorted(set(HIDDEN_PROFESSION_IDS) - set(content_rows))
|
||||
missing_art = sorted(set(HIDDEN_PROFESSION_IDS) - set(art_rows))
|
||||
if missing_content:
|
||||
mismatch_errors.append("content is missing {}".format(", ".join(missing_content)))
|
||||
if missing_art:
|
||||
mismatch_errors.append("art is missing {}".format(", ".join(missing_art)))
|
||||
|
||||
for identifier in HIDDEN_PROFESSION_IDS:
|
||||
content = content_rows.get(identifier)
|
||||
art = art_rows.get(identifier)
|
||||
if content is None or art is None:
|
||||
continue
|
||||
art_source_lines = art_document.text.splitlines()
|
||||
row_text = (
|
||||
art_source_lines[art.line - 1]
|
||||
if 0 < art.line <= len(art_source_lines)
|
||||
else " | ".join(
|
||||
(art.identity, " ".join(art.parents), art.translation, art.props, art.vfx, art.actions)
|
||||
)
|
||||
)
|
||||
if HIDDEN_ART_UNRESOLVED_RE.search(row_text) or has_pending_marker(row_text):
|
||||
issues.append(
|
||||
issue(
|
||||
"HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED",
|
||||
ART_PATH,
|
||||
art.line,
|
||||
1,
|
||||
"{} retains unresolved identity, parent, mechanic, or production binding language".format(
|
||||
identifier
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
ordinal = int(identifier[-2:])
|
||||
expected_art_id = "ART-PRO-H{:02d}".format(ordinal)
|
||||
expected_content_id = "CONTENT.PROFESSION.HIDDEN[{:02d}]".format(ordinal)
|
||||
identity_plain = semantic_plain_text(art.identity)
|
||||
if (
|
||||
expected_art_id.lower() not in identity_plain
|
||||
or expected_content_id.lower() not in identity_plain
|
||||
or identifier.lower() not in identity_plain
|
||||
or content.name.lower() not in identity_plain
|
||||
):
|
||||
mismatch_errors.append("{} does not bind its exact art/content identity and name".format(identifier))
|
||||
if len(content.parents) != 2 or art.parents != content.parents:
|
||||
mismatch_errors.append(
|
||||
"{} art parents {} do not equal content parents {}".format(
|
||||
identifier,
|
||||
"+".join(art.parents) if art.parents else "none",
|
||||
"+".join(content.parents) if content.parents else "none",
|
||||
)
|
||||
)
|
||||
|
||||
fields = {
|
||||
"silhouette translation": art.translation,
|
||||
"props/anchors": art.props,
|
||||
"VFX/event cue": art.vfx,
|
||||
"animation/action verbs": art.actions,
|
||||
}
|
||||
for label, value in fields.items():
|
||||
if not semantic_terms(value):
|
||||
mismatch_errors.append("{} has no resolved {} semantics".format(identifier, label))
|
||||
|
||||
mechanic_overlap = semantic_terms(content.mechanic) & semantic_terms(art.translation)
|
||||
if len(mechanic_overlap) < 2:
|
||||
mismatch_errors.append(
|
||||
"{} silhouette translation does not express its content mechanic".format(identifier)
|
||||
)
|
||||
if len(content.production_segments) < 3:
|
||||
mismatch_errors.append(
|
||||
"{} content production brief has fewer than three semantic channels".format(identifier)
|
||||
)
|
||||
continue
|
||||
production_targets = (
|
||||
("silhouette", art.translation),
|
||||
("props", art.props),
|
||||
("action", art.actions),
|
||||
("VFX", art.vfx),
|
||||
)
|
||||
visual_terms = semantic_terms(content.production_segments[0])
|
||||
for label, value in production_targets[:2]:
|
||||
if not visual_terms or not (visual_terms & semantic_terms(value)):
|
||||
mismatch_errors.append("{} {} does not bind the content visual brief".format(identifier, label))
|
||||
action_terms = semantic_terms(content.production_segments[1])
|
||||
if not action_terms or not (action_terms & semantic_terms(art.actions)):
|
||||
mismatch_errors.append("{} actions do not bind the content motion brief".format(identifier))
|
||||
vfx_terms = semantic_terms(content.production_segments[2])
|
||||
if not vfx_terms or not (vfx_terms & semantic_terms(art.vfx)):
|
||||
mismatch_errors.append("{} VFX does not bind the content cue brief".format(identifier))
|
||||
combined_art = " ".join((art.translation, art.vfx, art.actions))
|
||||
for extra in content.production_segments[3:]:
|
||||
extra_terms = semantic_terms(extra)
|
||||
if not extra_terms or not (extra_terms & semantic_terms(combined_art)):
|
||||
mismatch_errors.append(
|
||||
"{} art does not bind production brief segment '{}'".format(
|
||||
identifier, normalize_cell(extra)
|
||||
)
|
||||
)
|
||||
|
||||
if mismatch_errors:
|
||||
issues.append(
|
||||
issue(
|
||||
"HIDDEN_PROFESSION_ART_MISMATCH",
|
||||
ART_PATH,
|
||||
min((row.line for row in art_rows.values()), default=1),
|
||||
1,
|
||||
"hidden content/art bindings are incomplete or incompatible: {}".format(
|
||||
"; ".join(sorted(set(mismatch_errors)))
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def endpoint_kind(value: str) -> Optional[str]:
|
||||
exposed = re.search(r"\b(?:exposed|front)\b", value, re.IGNORECASE) is not None
|
||||
protected = re.search(r"\b(?:protected|rear|last)\b", value, re.IGNORECASE) is not None
|
||||
@@ -2201,8 +2823,10 @@ def audit(root: Path) -> AuditResult:
|
||||
validate_pending_bindings(documents, issues)
|
||||
validate_retired_concepts(documents, issues)
|
||||
validate_provisional_selection_presentation(documents, definitions, issues)
|
||||
validate_run_creation_transaction(documents, issues)
|
||||
validate_party_direction(documents, issues)
|
||||
validate_recruit_art_crosswalk(documents, issues)
|
||||
validate_hidden_profession_art(documents, issues)
|
||||
validate_screen_coverage(documents, definitions, issues, counts)
|
||||
validate_art_coverage(documents, references, issues)
|
||||
validate_audio_coverage(
|
||||
@@ -2232,7 +2856,46 @@ def write_fixture(root: Path) -> None:
|
||||
"No relationship system, six-unit cap, three-lane grid, Directive, Anchor, or Cache remains.\n"
|
||||
),
|
||||
"ECONOMY_AND_BALANCE.md": "# Economy\n\nRecruitment, dismissal, rewards, and replacement have explicit costs.\n",
|
||||
"SAVE_AND_FAILURE_CONTRACT.md": "# Save\n\nDeath, failure, rollback, and recovery are explicit.\n",
|
||||
"SAVE_AND_FAILURE_CONTRACT.md": """# Save
|
||||
|
||||
Death, failure, rollback, and recovery are explicit.
|
||||
|
||||
## 2. Save Topology And Envelope
|
||||
|
||||
```text
|
||||
run_creation_draft
|
||||
draft_id, run_id, status, run_seed
|
||||
opening_offers[0..3] or empty, opening_offer_set_hash
|
||||
selected_offer_ids[2] or empty, selection_revision
|
||||
current_run or null
|
||||
```
|
||||
|
||||
The run_creation_draft and current_run are mutually exclusive.
|
||||
|
||||
## 6. Transaction Boundaries
|
||||
|
||||
| Action | Pending intent contains | Applied child contains |
|
||||
|---|---|---|
|
||||
| Begin run creation | Contract and derived seed | one seed_reserved run_creation_draft; no offers or current_run |
|
||||
| Materialize opening offers | draft ID and run seed hash | all four offer records and IDs, output hashes, and one offer-set hash |
|
||||
| Set opening selection | draft ID, expected revision, and exactly two distinct offer IDs | same four offers, canonical pair, selection revision, and receipt |
|
||||
| Commit run creation | draft ID, offer-set hash, selection revision, and exact pair | draft removed; same run seed and selected Recruit records in current_run; run-start receipt |
|
||||
|
||||
## 7. Run Creation
|
||||
|
||||
### 7.1 Seed-First Run Creation
|
||||
|
||||
The applied Begin operation durably creates a seed_reserved run_creation_draft,
|
||||
then it is flushed and read back before the opening generator may run.
|
||||
|
||||
The player may revise a different legal pair and increment selection_revision
|
||||
until Run commit without regenerating any opening offer.
|
||||
|
||||
Commit is one atomic state: it removes the draft, creates current_run with the
|
||||
same run_seed, transfers the two unchanged Recruit records, and stores the
|
||||
original receipt. Duplicate or recovery retries return that original receipt
|
||||
and cannot create a second Run.
|
||||
""",
|
||||
}
|
||||
for name, text in product_texts.items():
|
||||
(root / "docs/product" / name).write_text(text, encoding="utf-8")
|
||||
@@ -2249,8 +2912,21 @@ def write_fixture(root: Path) -> None:
|
||||
profession.append(table(numbered_ids("PF-B", 12, 2), 6))
|
||||
profession.extend(("", "## Regular Advanced Professions", ""))
|
||||
profession.append(table(numbered_ids("PF-A", 24, 2), 5))
|
||||
profession.extend(("", "## Hidden Hybrid Professions", ""))
|
||||
profession.append(table(numbered_ids("PF-H", 6, 2), 5))
|
||||
profession.extend(
|
||||
(
|
||||
"",
|
||||
"## Hidden Hybrid Professions",
|
||||
"",
|
||||
"| ID | Name; required bases; deed | Role/band; core rules | Target/reach and automatic cadence | Counters; synergies | Production needs |",
|
||||
"|---|---|---|---|---|---|",
|
||||
"| `PF-H01` | Wardmender; `PF-B01` + `PF-B07`; restore intercepted harm | guard-restore; intercepted harm becomes repair energy for the protected ally. | reach 1; brace, then mend the oldest harm. | Anti-heal delays repair. | `P/O/A/V/S`: mantle buckler, brace/mend, square pulse, repair chime. |",
|
||||
"| `PF-H02` | Tempo Cutter; `PF-B02` + `PF-B11`; alter a timed strike | tempo-pressure; cadence Edge shortens an enemy telegraph between ally actions. | reach 2; step between beats, then cut. | Silence breaks cadence. | `P/O/A/V/S`: bell blades, step/cut, split metronome, cadence strike. |",
|
||||
"| `PF-H03` | Farline Lancer; `PF-B03` + `PF-B05`; pierce a rear target | projectile-reach; a planted bow-spear guides a piercing rear shot. | line reach; plant, draw, then recover. | Movement breaks the guide. | `P/O/A/V/S`: bow-spear, plant/draw/recover, horizon line, guided shot. |",
|
||||
"| `PF-H04` | Route Smith; `PF-B04` + `PF-B09`; change a device by position | reorder-construct; a carried automaton changes guard, strike, and pull modes by indexed position. | adjacent reach; carry, drop, and switch. | Roots stop modes. | `P/O/A/V/S`: pack automaton, carry/drop/switch, numbered footprints, mode click. |",
|
||||
"| `PF-H05` | Eclipse Scribe; `PF-B06` + `PF-B10`; retain an interrupted cast | area-impair; an interrupted charge becomes shadow text released on hindrance expiry. | line spell; collapse, inscribe, release. | Cleanse erases text. | `P/O/A/V/S`: split tablet, collapse/inscribe/release, eclipsed diamond, reversed charge. |",
|
||||
"| `PF-H06` | Loan Factor; `PF-B08` + `PF-B12`; share distinct resources | enable-supply; linked loans repay as flexible Credit after recipient actions. | reach 2; loan, mark, then close. | Isolation creates bad debt. | `P/O/A/V/S`: ledger-knot frame, loan/mark/close, linked tabs, stamped pluck. |",
|
||||
)
|
||||
)
|
||||
profession.extend(
|
||||
(
|
||||
"",
|
||||
@@ -2335,6 +3011,14 @@ body_frame, face_shape, skin_palette, hair_shape, hair_palette
|
||||
garment_cut, garment_palette, profession_overlay, detail_mark
|
||||
```
|
||||
|
||||
### 2.1 Opening Four-Offer Binding
|
||||
|
||||
Opening generation is legal only from a validated, read-back
|
||||
`run_creation_draft` created by an applied seed-reservation operation.
|
||||
All four complete records, their offer IDs, and output hashes enter one
|
||||
materialization commit. Selecting or revising the accepted exact pair does not
|
||||
regenerate or mutate an offer.
|
||||
|
||||
## 4. Modular Visual Grammar
|
||||
|
||||
| Part | Options | Rule |
|
||||
@@ -2454,6 +3138,17 @@ Region coverage: `ART-REG-01..08`.
|
||||
| `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 |
|
||||
|
||||
### 5.4 Hidden Hybrid Profession Kits: 6
|
||||
|
||||
| Art ID / content identity | Exact parent pair | Combat-to-silhouette translation | Held props and anchors | Material | VFX / event cue | Animation needs / action verbs |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `ART-PRO-H01` / `CONTENT.PROFESSION.HIDDEN[01]` / `PF-H01` Wardmender | `PF-B01` + `PF-B07` | A mantle buckler braces intercepted harm and opens repair energy toward the protected ally. | Mantle buckler and repair lamp. | iron and cloth | A square pulse closes with a repair chime. | `BRACE` (brace/mend) |
|
||||
| `ART-PRO-H02` / `CONTENT.PROFESSION.HIDDEN[02]` / `PF-H02` Tempo Cutter | `PF-B02` + `PF-B11` | Bell blades show cadence Edge shortening an enemy telegraph between ally actions. | Paired bell blades and beat tabs. | steel and brass | A split metronome marks cadence. | `MOBILE` (step/cut/strike) |
|
||||
| `ART-PRO-H03` / `CONTENT.PROFESSION.HIDDEN[03]` / `PF-H03` Farline Lancer | `PF-B03` + `PF-B05` | A bow-spear plants a guide for a piercing rear shot and recovery. | Bow-spear with a ground plant. | wood and steel | One horizon line marks the guided shot. | `RELEASE` (plant/draw/recover) |
|
||||
| `ART-PRO-H04` / `CONTENT.PROFESSION.HIDDEN[04]` / `PF-H04` Route Smith | `PF-B04` + `PF-B09` | A pack automaton changes guard, strike, and pull mode at each indexed position. | Pack automaton with a ground anchor. | steel and canvas | Numbered footprints close with a mode click. | `DEPLOY` (carry/drop/switch) |
|
||||
| `ART-PRO-H05` / `CONTENT.PROFESSION.HIDDEN[05]` / `PF-H05` Eclipse Scribe | `PF-B06` + `PF-B10` | A split tablet stores interrupted charge as shadow text until hindrance expiry. | Split tablet at the effect anchor. | glass and wood | An eclipsed diamond reverses the charge. | `CHANNEL` (collapse/inscribe/release) |
|
||||
| `ART-PRO-H06` / `CONTENT.PROFESSION.HIDDEN[06]` / `PF-H06` Loan Factor | `PF-B08` + `PF-B12` | A ledger-knot frame shows linked loans repaid as flexible Credit after recipient actions. | Ledger-knot frame and linked loan tabs. | wood and cord | Linked tabs close with a stamped pluck. | `PULSE` (loan/mark/close) |
|
||||
"""
|
||||
(root / "docs/presentation/ART_ASSET_AND_ANIMATION_CATALOG.md").write_text(art, encoding="utf-8")
|
||||
|
||||
@@ -2665,6 +3360,45 @@ def mutate_integrated_reference_scope_fixture(root: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def mutate_seed_order_fixture(root: Path) -> None:
|
||||
replace_fixture_text(
|
||||
root / SAVE_PATH,
|
||||
"read back before the opening generator may run",
|
||||
"read back after the opening generator may run",
|
||||
)
|
||||
|
||||
|
||||
def mutate_incomplete_run_creation_transaction_fixture(root: Path) -> None:
|
||||
replace_fixture_text(
|
||||
root / SAVE_PATH,
|
||||
"Commit is one atomic state:",
|
||||
"Commit creates a state:",
|
||||
)
|
||||
|
||||
|
||||
def mutate_hidden_profession_mechanic_fixture(root: Path) -> None:
|
||||
art_path = root / ART_PATH
|
||||
replacements = (
|
||||
(
|
||||
"Bell blades show cadence Edge shortening an enemy telegraph between ally actions.",
|
||||
"A bow arc crosses a rear vial wing while the front hand stays clear.",
|
||||
),
|
||||
("Paired bell blades and beat tabs.", "A long bow, rear vials, and a projectile anchor."),
|
||||
("A split metronome marks cadence.", "A mist cloud marks deployed ammunition."),
|
||||
("`MOBILE` (step/cut/strike)", "`RELEASE` (draw/deploy)"),
|
||||
)
|
||||
for old, new in replacements:
|
||||
replace_fixture_text(art_path, old, new)
|
||||
|
||||
|
||||
def mutate_hidden_profession_unresolved_fixture(root: Path) -> None:
|
||||
replace_fixture_text(
|
||||
root / ART_PATH,
|
||||
"| `PF-B02` + `PF-B11` |",
|
||||
"| `CONTENT.PROFESSION.HIDDEN[02].parents` |",
|
||||
)
|
||||
|
||||
|
||||
def mutate_remove_line(path: Path, needle: str) -> None:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
@@ -2874,6 +3608,26 @@ def run_self_test() -> None:
|
||||
mutate_integrated_reference_scope_fixture,
|
||||
"REFERENCE_RETIRED_SCOPE",
|
||||
),
|
||||
(
|
||||
"run-creation-seed-order",
|
||||
mutate_seed_order_fixture,
|
||||
"RUN_CREATION_SEED_ORDER_MISMATCH",
|
||||
),
|
||||
(
|
||||
"run-creation-transaction",
|
||||
mutate_incomplete_run_creation_transaction_fixture,
|
||||
"RUN_CREATION_TRANSACTION_INCOMPLETE",
|
||||
),
|
||||
(
|
||||
"hidden-profession-mechanic",
|
||||
mutate_hidden_profession_mechanic_fixture,
|
||||
"HIDDEN_PROFESSION_ART_MISMATCH",
|
||||
),
|
||||
(
|
||||
"hidden-profession-unresolved",
|
||||
mutate_hidden_profession_unresolved_fixture,
|
||||
"HIDDEN_PROFESSION_ART_BINDING_UNRESOLVED",
|
||||
),
|
||||
)
|
||||
isolated_semantic_cases = {
|
||||
"provisional-selection-presentation",
|
||||
@@ -2881,6 +3635,10 @@ def run_self_test() -> None:
|
||||
"recruit-art-crosswalk",
|
||||
"audio-screen-inventory",
|
||||
"reference-retired-scope",
|
||||
"run-creation-seed-order",
|
||||
"run-creation-transaction",
|
||||
"hidden-profession-mechanic",
|
||||
"hidden-profession-unresolved",
|
||||
}
|
||||
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