feat(parser): lift "[once] for each ⟨player-set⟩" onto fieldless Investigate - #6110
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
🤖 AI text below 🤖 @matthewevans ready for review + enqueue when you have a moment. This closes the same missing-wire class you flagged on #6108 (Bumi): a parser-only lift of a trailing "[once] for each ⟨player-set⟩" clause onto a fieldless Gates green on head 67da88d (rebased on current main 7afcff9): |
|
Maintainer update: the implementation re-review on the current head is clean. Approval remains held until GitHub publishes the current-head |
Parse changes introduced by this PR · 8 card(s), 10 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — the player-set lift is at the right chain-lowering seam, but it deliberately leaves the same fieldless investigate for each … grammar rules-incorrect for object sets.
🔴 Blocker
crates/engine/src/parser/oracle_effect/lower.rs:3581-3586accepts onlyQuantityRef::PlayerCount; the newcrates/engine/tests/integration/teysa_wojek_investigate_per_opponent.rs:404regression expressly permits object-setinvestigate for eachto remain without a repeat count. The verified Oracle text for Serene Sleuth is “investigate for each goaded creature you control,” and Sophina, Spearsage Deserter says “investigate once for each nontoken attacking creature.”repeat_foralready models an integer count, so neither needs per-object binding: both must create one Clue per matching creature, not silently resolve as one Investigate. CR 701.16a defines investigate as creating a Clue; CR 608.2c requires following the full instruction. Generalize the fieldless-effect suffix lift to retain every supportedQuantityRef(includingObjectCount) and add a runtime discriminator for at least one object-set card.
🟡 Non-blocking
- The current-head
<!-- coverage-parse-diff -->artifact says “No card-parse changes detected,” whereas the PR comment claims exactly{Teysa, Wojek}(+2). This prevents the manual Quality Gate from passing or aqualitylabel; it is not evidence that the behavior change is absent. Reconcile the claimed parse-impact statement after the blocker is fixed.
✅ Clean
- The change is in
parse_effect_chain_ir, the appropriate repeat attachment seam, and the Teysa/Wojek tests exercise real parse and scenario-resolution paths.
Recommendation: extend the lift across the complete supported fieldless-Investigate for each class (player and object counts), then re-request review with the regenerated parse-diff evidence.
matthewevans
left a comment
There was a problem hiding this comment.
The current-head parse-diff sticky says “No card-parse changes detected,” but that is a coverage false-green rather than evidence that this change is inert.
This PR attaches repeat_for to otherwise fieldless Effect::Investigate for the Teysa/Wojek player sets (oracle_effect/mod.rs), so the parse tree materially changes. The coverage parse tree cannot report it: build_ability_item merges effect_details and ability_details, but ability_details does not project AbilityDefinition::repeat_for (crates/engine/src/game/coverage.rs:3592), and Effect::Investigate deliberately has no effect detail (:3521). The separate feature extractor does recognize repeat_for (:7050), but that does not feed the parse-diff sticky.
Please project repeat_for (including its quantity) into ability parse details and add a regression proving this Teysa/Wojek class produces a parse-diff signature. Then the CI artifact can serve as the claimed card-level evidence.
…stigate
Lift a trailing "[once] for each <player-set>" clause on a fieldless
Effect::Investigate into repeat_for = PlayerCount{filter}:
- Teysa, Opulent Oligarch: "investigate for each opponent who lost life
this turn" → PlayerCount{OpponentLostLife}
- Wojek Investigator: "investigate once for each opponent who has more
cards in hand than you" → PlayerCount{PlayerAttribute{Opponent,
HandSize{ScopedPlayer}, GT, HandSize{Controller}}}
EDIT 1 extracts a shared split_for_each_suffix primitive (byte-identical
strip refactor) plus a PlayerCount-gated for_each_player_set_repeat_for
lift, consumed at the chunk-loop seam. EDIT 2 adds the comparative
hand-size who-clause combinator and widens the player-attribute operand
to QuantityExpr.
Parser-only: runtime resolution rides the existing filter-agnostic
repeat_for driver (CR 608.2c). Object-ranged "investigate for each"
(Serene Sleuth, Sophina) stays on the unchanged path.
Assisted-by: ClaudeCode:claude-opus-4.8
…r-count class Responds to the phase-rs#6110 review: the fieldless-Investigate "for each ⟨set⟩" lift was gated to QuantityRef::PlayerCount only, so object-ranged cards (Serene Sleuth: "investigate for each goaded creature you control") dropped to a bare 1-Clue Investigate (rules-wrong, CR 701.16a/608.2c). - Gate-widen: rename for_each_player_set_repeat_for -> for_each_repeatable_repeat_for and widen the single-seam eligibility gate to the member-count class matches!(qty, PlayerCount{..} | ObjectCount{..}) with a fail-closed _ => None. Preserves Teysa/Wojek (PlayerCount) + Serene Sleuth (ObjectCount); a contextual amount-ref (e.g. Tamiyo's "investigate twice for each card discarded", FilteredTrackedSetSize) is deliberately NOT lifted. Locked by a revert-probed negative-boundary test. - Gap A: add FilterProp::Goaded (candidate-local read of GameObject.goaded_by, CR 701.15b/c) across all 15 registration sites, mirroring FilterProp::Renowned. - ASK 2: project repeat_for into coverage.rs ability_details (None -> byte-identical). - Gap B (deferred): Sophina "nontoken attacking creature" strict-failure tag + flipping tripwire. Collateral (measured via regen-both at merge-base 931c2dc, ZERO regressions): FilterProp::Goaded also correctly parses the "goaded creature" filter on 4 previously-Unknown/degraded cards (Bothersome Quasit CantBlock static; Puppet Master / The Rani / Vengeful Ancestor triggers). A revert-probed runtime drive (vengeful_ancestor_goaded_attack_trigger.rs) proves the trigger-subject Goaded eval resolves against the LIVE attacker's goaded_by (CR 508.2a/603.2), not the fieldless EventObjectSnapshot -- so these are genuinely supported, not false-supported. Assisted-by: ClaudeCode:claude-opus-4.8
67da88d to
29e9ad9
Compare
|
🤖 AI text below 🤖 Addressed the two requested changes. Blocker 1 — generalize the fieldless- Blocker 2 — project Deferred — Sophina, Spearsage Deserter. It remains a bare Investigate (one Clue). Its "nontoken attacking creature" filter is blocked by a separate, pre-existing |
|
Maintainer update: I have received the new head and its response to the prior requested changes. I am holding the implementation review until GitHub publishes the current-head |
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — the object-count runtime fix may be sound, but the current-head parse-diff exposes a 250-card coverage blast radius that the claimed seven-card scope does not account for.
🔴 Blocker
[HIGH] repeat_for coverage projection changes the parse signature of the whole existing repeat-for surface. The current <!-- coverage-parse-diff --> reports 250 changed cards / 158 signatures, including many unrelated existing repeat_for effects (for example CopySpell, Token, Proliferate, SearchLibrary, and dozens of Investigate forms), while the PR describes exactly seven card changes. The new coverage.rs projection therefore makes this focused Investigate/Goaded feature alter card-level parse output across the global repeat-for population.
Why it matters: the advertised parse-impact evidence is no longer a bounded witness for the intended class, and downstream parse-diff consumers now see hundreds of unrelated changes. Suggested fix: restrict the coverage signature change to the intended fieldless-Investigate lift class, or explicitly split and independently validate a deliberate global coverage-schema migration with a complete compatibility/consumer audit and reconciled artifact.
✅ Clean
- The current artifact does show Serene Sleuth, Teysa, Wojek, and the goaded-filter cards as real changes; this finding concerns the additional 243 cards.
Recommendation: split or narrow the coverage projection, then regenerate the parse-diff and re-request review.
…ate lift class (phase-rs#6110) The global repeat_for projection in `ability_details` surfaced a new signature key on every card carrying repeat_for, migrating ~250 parse-identical cards' coverage signatures at once. Scope it to the lift's own eligibility set — a fieldless `Effect::Investigate` with a member-count `QuantityRef` (`PlayerCount`/`ObjectCount`), mirroring `for_each_repeatable_repeat_for` — so only the intended lift class surfaces. The real `coverage-parse-diff` vs the merge-base now reports 8 cards / 10 signatures (7 real AST changes + Ethereal Investigator, a pre-existing Investigate+PlayerCount that is byte-identical), down from 250. A revert-probed `investigate_signature_exposes_repeat_for` regression (2 positive + 4 negative cases) locks the scope. Assisted-by: ClaudeCode:claude-opus-4.8
|
🤖 AI text below 🤖 Addressed the coverage parse-diff blast-radius finding. The Measured with the
A revert-probed |
matthewevans
left a comment
There was a problem hiding this comment.
**Request changes — the scoped coverage projection resolves the prior blast-radius finding, but FilterProp::Goaded is now declared snapshot-answerable without snapshotting the designation.
🔴 Blocker
[HIGH] Event/LKI snapshot filtering silently treats a goaded subject as ungoaded. Evidence: crates/engine/src/types/events.rs:301-344 has no goad field, while :562-574 classifies FilterProp::Goaded as Supported; the live predicate reads obj.goaded_by at crates/engine/src/game/filter.rs:4302-4307, and the LKI adapter constructs a ZoneChangeRecord without a goad designation at :1509-1555. Why it matters: any reachable past-event or LKI subject filter using goaded becomes false after the object leaves the battlefield, despite the structural gate representing it as supported. Suggested fix: carry a capture-time goaded fact through the snapshot/record evaluators (and their serialization/construction sites), or classify this predicate unsupported until that full path exists; add a departure/LKI discriminating regression.
Recommendation: request changes for the snapshot contract, then re-run the current-head review.
…ubject reach gate The event-subject reach-gate classifier (classify_prop in types/events.rs) marked FilterProp::Goaded as Supported, asserting a goaded event-subject filter is answerable from an EventObjectSnapshot. It is not: the snapshot carries no goaded field, and the runtime zone-change-record matcher already fail-closes Goaded (game/filter.rs). The certificate was fail-open. Reclassify FilterProp::Goaded as Unsupported, aligning the reach-gate certificate with the snapshot's actual fields and the runtime's existing fail-closed behavior, so a future goaded event-subject filter fails the reach gate loudly rather than silently reading an ungoaded snapshot. Pure contract + test change with zero behavior/coverage delta: the reach gate is test-only latent scaffolding (classify_prop/classify_filter_shape have no production caller), coverage is supplied independently by coverage.rs, and the live goaded predicate (filter.rs FilterProp::Goaded => !obj.goaded_by.is_empty()) is untouched. card-data.json byte-identical before/after (4-card + full-DB). Adds a revert-probed test goaded_subject_filter_is_unsupported. Deferred follow-up (option a): snapshot goad onto EventObjectSnapshot + ZoneChangeRecord and reclassify back to Supported. CR 701.15b/c: goad is a designation on the live permanent (its goaded_by set). Assisted-by: ClaudeCode:claude-opus-4.8
|
🤖 AI text below 🤖 Addressed the [HIGH]
Fix: reclassified This is a pure contract + test change with zero behavior/coverage delta:
Evidence:
Deferred follow-up (option a): snapshot goad onto |
matthewevans
left a comment
There was a problem hiding this comment.
Approved on the updated current head. The prior blocker is resolved by classifying goaded event-subject filters as snapshot-unsupported, so the existing runtime fail-closed behavior is reflected by the reach gate rather than silently certifying a missing LKI field. The maintainer branch update merged current main without a semantic conflict; merge queue will wait for the restarted checks.
…+ resolution The DynQty parser fix (e82f871) correctly narrowed Too Evil to Stay Dead's base branch to "mana value 4 or less", which unmasked a two-layer pre-existing engine gap that prevented the teamwork "instead" broadening from working: - Cast-time (CR 601.2b/601.2c/702.194c): additional_cost_paid pre-target propagation was kicker-only, so a teamwork/bargain-paid spell built targets against the narrow base filter. Generalize the deferral to every AdditionalCost-"instead" with a non-empty effective queue (new helper build_effective_additional_cost_queue; else-if arms at the live-cast and preview gates; begin_target_dependent_additional_cost_declaration made exhaustive with the kicker arm byte-identical). The castability precheck is renamed additional_cost_instead_spell_has_legal_targets and gated on the same requires_additional_cost_declaration_before_targets predicate as the cast-time gate, so context-ref "instead" overrides (Bargain "that permanent" = ParentTarget, e.g. Torch the Tower) are not misreported castable. - Resolution (CR 608.2b): apply_instead_swap preserved the parent node's targets, which per-node re-validation had emptied under the narrow base filter, and discarded the override node's own validated target -> the swapped-in effect resolved with zero targets (silent no-op). Take the override's validated targets when the parent's were emptied. Repairs teamwork-cast Too Evil to Stay Dead / Cruel Alliance and kicked Bloodchief's Thirst (base-illegal/kicked-legal target previously no-op'd). Bounded: functional surface = 2 teamwork cards + ~6 kicker cards, all no-op -> correct; other three instead-condition kinds and same-filter/context-ref cases byte-identical. Engine-only: card-data regen byte-identical (0 parse/coverage delta). Full cargo test -p engine --lib (17011) + integration (3388) green. Completes the option-(a) fix for phase-rs#6143 (DynQty series; predecessor phase-rs#6110). Assisted-by: ClaudeCode:claude-opus-4.8
…aveyard/zone targets (phase-rs#6143) * feat(parser): capture mana-value ceiling trailing a zone clause on graveyard/zone targets Add a post-zone parse_mana_value_suffix second pass in parse_type_phrase_with_ctx, mirroring the existing zone->counter and zone->without second passes, so a mana-value clause that TRAILS a zone clause ("target ... card in your graveyard with mana value less than or equal to ...") is captured on the target filter instead of being silently swallowed as Swallow:DynamicQty. Unlocks Squirming Emergence (dynamic Cmc{LE, ZoneCardCount{Graveyard,[Permanent], Controller}} ceiling) and correctly restricts the whole zone-then-mana-value class: Lazav the Multifarious + Likeness Looter (Cmc{EQ, Variable(X)} + their now-parsed ', except ...' modifications), Too Evil to Stay Dead's base branch (Cmc{LE, Fixed 4}), Scrap Trawler (Cmc{LT, ObjectManaValue}), and Devoted Sultai's phrase-level ceiling. Pre-existing engine gap unmasked (documented at the parser + engine sites, no tracker): AdditionalCost-'instead' cast-time target-filter broadening is propagated for kicker only, so Too Evil to Stay Dead's teamwork branch now uses its correctly-narrowed base filter. Two prior bugs canceled (dropped suffix left the base branch broad); the correct parser fix unmasks the engine gap. The fix generalizes kicker -> all AdditionalCost- 'instead' (parameterize-don't-proliferate) as a separate engine change. CR 202.3 (mana value), CR 608.2c (later text modifies earlier), CR 115.2 (targets in a non-battlefield zone must be specified). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(engine): resolve additional-cost-"instead" broad targets at cast + resolution The DynQty parser fix (e82f871) correctly narrowed Too Evil to Stay Dead's base branch to "mana value 4 or less", which unmasked a two-layer pre-existing engine gap that prevented the teamwork "instead" broadening from working: - Cast-time (CR 601.2b/601.2c/702.194c): additional_cost_paid pre-target propagation was kicker-only, so a teamwork/bargain-paid spell built targets against the narrow base filter. Generalize the deferral to every AdditionalCost-"instead" with a non-empty effective queue (new helper build_effective_additional_cost_queue; else-if arms at the live-cast and preview gates; begin_target_dependent_additional_cost_declaration made exhaustive with the kicker arm byte-identical). The castability precheck is renamed additional_cost_instead_spell_has_legal_targets and gated on the same requires_additional_cost_declaration_before_targets predicate as the cast-time gate, so context-ref "instead" overrides (Bargain "that permanent" = ParentTarget, e.g. Torch the Tower) are not misreported castable. - Resolution (CR 608.2b): apply_instead_swap preserved the parent node's targets, which per-node re-validation had emptied under the narrow base filter, and discarded the override node's own validated target -> the swapped-in effect resolved with zero targets (silent no-op). Take the override's validated targets when the parent's were emptied. Repairs teamwork-cast Too Evil to Stay Dead / Cruel Alliance and kicked Bloodchief's Thirst (base-illegal/kicked-legal target previously no-op'd). Bounded: functional surface = 2 teamwork cards + ~6 kicker cards, all no-op -> correct; other three instead-condition kinds and same-filter/context-ref cases byte-identical. Engine-only: card-data regen byte-identical (0 parse/coverage delta). Full cargo test -p engine --lib (17011) + integration (3388) green. Completes the option-(a) fix for phase-rs#6143 (DynQty series; predecessor phase-rs#6110). Assisted-by: ClaudeCode:claude-opus-4.8 * fix(PR-6143): preserve override target set Keep the override node’s complete validated targets whenever it owns a declared target filter; preserve parent targets for context-reference overrides. Co-authored-by: Lindsey Gray <lindsey.gray@gmail.com> --------- Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
🤖 AI text below 🤖
Summary
Generalize the fieldless-
Effect::Investigate"for each ⟨set⟩" lift fromPlayerCount-only to the parameterized member-count class (PlayerCount | ObjectCount), and add theFilterProp::Goadedbuilding block it needs. Addresses the #6110 review (the lift dropped object-ranged Investigate to a bare 1-Clue, rules-wrong CR 701.16a/608.2c).Net effect: 7 real parse (AST) changes, all strict improvements, ZERO regressions (confound-free regen-both
card-data.jsondiff at the merge-base931c2dc2f). Therepeat_forcoverage projection is scoped to the lift class (see below), so thecoverage-parse-diffsticky reports 8 cards / 10 signatures — those 7 plus one pre-existing, AST-identical card (Ethereal Investigator) whoserepeat_formerely surfaces in the scoped signature.repeat_forlifts: Teysa, Opulent Oligarch (PlayerCount, opponents who lost life) · Wojek Investigator (PlayerCount, comparative hand-size) · Serene Sleuth (ObjectCount, goaded creatures you control).FilterProp::Goadedcorrectly parsing "goaded creature" where it was previously Unknown/degraded — inherent to a general building block): Bothersome Quasit (CantBlock static) · Puppet Master, String Puller · The Rani · Vengeful Ancestor (goaded-subject triggers).matches!(PlayerCount | ObjectCount)eligibility gate excludes it, locked by a revert-probed negative-boundary test. Byte-identical to base.How this addresses the #6110 review
QuantityRefincl.ObjectCount+ a runtime discriminator for ≥1 object-set card) →lower.rsgate widenedPlayerCount-only →matches!(PlayerCount | ObjectCount);FilterProp::Goadedadded so "goaded creature you control" parses toObjectCount; the:404enshrining test inverted to require the object-set lift. Runtime discriminator:serene_sleuth_runtime_makes_one_clue_per_goaded_creature(3 goaded → 3 Clues vs Goaded-blind 5 vs bare 1, revert-probed). Serene Sleuth is fixed and proven.repeat_forprojection incoverage.rsability_detailsis scoped to the lift class — it surfaces therepeat_forsignature key only for a fieldlessEffect::Investigatewhoserepeat_foris a member-countQuantityRef(PlayerCount/ObjectCount), exactly the eligibility set offor_each_repeatable_repeat_for. It does not touch the rest of the repeat_for surface (CopySpell/Token/Proliferate/SearchLibrary, or pre-existingFixed/Variable/tracked-set Investigate forms). Measured with the realcoverage-parse-difftool against the merge-base (931c2dc2f, no-projection baseline): 8 cards / 10 signatures (down from the earlier global projection's 250), confound-free (0 oracle-text changes, 0 added/removed). That is 7 real parse changes (the 3 lifts Teysa/Wojek/Serene + the 4 collateral goaded-filter cards Bothersome Quasit, Puppet Master, The Rani, Vengeful Ancestor) plus 1 projection-only, AST-identical card — Ethereal Investigator, a pre-existingInvestigate+PlayerCountrepeat_for whose value simply becomes visible in the scoped signature (byte-identicalcard-data.json; structurally indistinguishable from the lifts, so no non-hacky scope excludes it). A revert-probedinvestigate_signature_exposes_repeat_forregression (2 positive member-count cases project; 4 negatives —None,Fixed, non-member-countRef, non-Investigate effect — do not) locks the scope. Broadeningrepeat_forcoverage to the whole surface is a deliberate global coverage-schema migration, deferred out of this focused feature.classify_propintypes/events.rshadFilterProp::Goadedin theSupportedgroup, asserting the reach gate could answer a goaded event-subject filter from anEventObjectSnapshot. But the snapshot carries nogoadedfield and the runtime zone-change-record matcher already fail-closes it (game/filter.rs~:5049, CR 701.15b/c) — an inconsistent, fail-open certificate. ReclassifiedFilterProp::Goaded→Unsupported, aligning the certificate with the snapshot's actual fields and the runtime's existing fail-closed behavior, so a future goaded event-subject filter fails the reach gate loudly rather than silently reading an ungoaded snapshot. Pure contract+test change with zero behavior/coverage delta: the reach gate is test-only latent scaffolding (classify_prop/classify_filter_shapehave no production caller today), coverage is supplied independently bycoverage.rs, and the live goaded predicate (filter.rsFilterProp::Goaded => !obj.goaded_by.is_empty()) is untouched. Measured: 4-card and full-DBcard-data.jsonbyte-identical before/after, plus a revert-probedgoaded_subject_filter_is_unsupportedtest (returning Goaded toSupportedmeasurably flips the assertion). Option-(a) snapshot-plumbing path is deferred (see Deferred follow-ups).parse_type_phraseleading-adjective order-dependence (thenontoken-leads-attackingordering returnsNonebefore the lift seam is reached), orthogonal to the gate fixed above. Deferred as a strict-failure tag + a flipping tripwire test (the Sophina branch of the inverted:404regression), because the order-freemany0fix is the hottest shared parser path. The blocker is answered at the seam level (ObjectCount retained, proven by Serene Sleuth); Sophina is a scoped follow-up.Implementation method (required)
Method: /engine-implementer
CR references
repeat_forloop reruns the fieldless Investigate once per iteration.FilterProp::GoadedreadsGameObject.goaded_by; multi-player set).valid_cardfilter is evaluated against the live attacker.HandSize).Verification
Required checks ran clean, or the exact CI-owned alternative is stated below.
Gate A output below is for the current committed head.
Final review-impl below is clean for the current committed head.
Both anchors cite existing analogous code at the same seam.
scripts/check-parser-combinators.sh upstream/main— Gate G + Gate A PASS (head 29e9ad9 base 931c2dc)cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets -- -D warnings— clean (0 warnings; post-rebase drift-verify) +cargo clippy -p engine --test integration -D warningsclean for the new testcargo test -p engine --lib— 40 passed (goaded / for_each_repeatable / dq_d_player_set_lift / investigate_signature + upstream goaded-combat)cargo test -p engine --test integration— feature subset passed (teysa/wojek runtime, serene runtime 3-way, object_for_each lift+Sophina-deferred guard, vengeful goaded-attack pair)Regen-both
card-data.jsondiff (base931c2dc2f= merge-base, feature head; same MAIN corpus symlinked into both worktrees, full per-card face diff): 35,396 cards both sides, no only-in-X ⇒ confound-free. 7 real AST changes, all strict improvements, 0 regressions. Byte-identity confirmed on Tamiyo Meets the Story Circle, Confront the Unknown, Declaration in Stone, Tranquil Frillback.coverage-parse-difftool (real binary, base931c2dc2fno-projection snapshot vs the scoped-projection head; base verified: 0repeat_forkeys, 15 Investigate+repeat_for with the 3 lifts absent = no-lift): 8 cards / 10 signatures, confound-free (oracle_changed=0, added=0, removed=0). = the 7 AST cards + Ethereal Investigator (projection-only, AST-identical). Down from the earlier global projection's 250.Runtime discrimination (apply()-drives, revert-probed)
ObjectCountcount context):serene_sleuth_runtime_makes_one_clue_per_goaded_creature— 3 goaded + ungoaded Sleuth + 1 plain → 3 Clues (vs Goaded-blind 5, vs bare 1); reverting the Goaded eval flips it.vengeful_ancestor_goaded_attack_trigger.rs— goaded attacker → controller loses 1 (trigger fires); ungoaded attacker → no loss (does not fire); legs differ only in goaded state. Production-line revert-probe (filter.rs FilterProp::Goaded => false) flips the goaded leg. Proves the triggervalid_cardGoaded filter resolves against the live attacker'sgoaded_by, not the fieldlessEventObjectSnapshot— so the collateral cards are genuinely supported, not false-supported.PlayerCount):teysa_runtime_*,wojek_runtime_*.The
repeat_fordriverdrive_repeat_for_outermost(crates/engine/src/game/effects/mod.rs) resolves the quantity generically with zero per-filter branching.Gate A
Gate A PASS head=29e9ad967 base=931c2dc2f
Anchored on
crates/engine/src/parser/oracle_effect/lower.rs—for_each_repeatable_repeat_for(renamed/widened fromfor_each_player_set_repeat_for) shares thesplit_for_each_suffixprimitive with the pre-existingstrip_for_each_repeat_suffixlift at the same seam.crates/engine/src/parser/oracle_effect/mod.rs— the existingrepeat_for: Some(QuantityExpr::Ref{..})chunk-seam lift the widenedelse if let Some(..)branch follows.crates/engine/src/game/filter.rs/types/ability.rs—FilterProp::GoadedmirrorsFilterProp::Renownedacross all 15 registration sites (candidate-local read ofgoaded_by).Final review-impl
Final review-impl PASS head=29e9ad967 (independent /review-impl, zero blocking findings; the collateral live-eval risk was subsequently measured and cleared by the Vengeful Ancestor runtime pair).
Claimed parse impact
coverage-parse-diffreports 8 cards / 10 signatures (confound-free vs merge-base931c2dc2f):7 real parse (AST) changes:
1 projection-only, AST-identical (disclosed, not a regression):
Investigate+PlayerCountrepeat_for; byte-identicalcard-data.json, surfaces only because the scoped coverage signature now shows itsrepeat_for. Structurally indistinguishable from the lifts, so no non-hacky scope excludes it.Deferred follow-ups
Recorded in-code as a strict-failure tag with a flipping tripwire test (no silent drop):
parse_type_phraseleading-adjective order-dependence (Sophina, Spearsage Deserter — "nontoken attacking creature"). The leading-adjective stripping is a fixed positional cascade (combat-status → … →nontoken); a phrase whose adjectives cross that order is only partly stripped, soparse_for_each_clausereturnsNoneand the per-attacker Investigate stays honestly bare (no false lift). Fix: collapse the cascade into an order-freemany0-style property loop — the hottest shared parser path (high CI-coverage-regression blast radius), hence deferred. Tripwire: the Sophina branch ofobject_for_each_investigate_is_liftedasserts the current bare-Investigate state and flips to fail when the gap closes.EventObjectSnapshotgoaded-projection certificate — resolved in this PR (option b, fail-closed reclassify; see the review-addressing section above). The remaining deferred (option a) is the fuller fix: snapshot goad ontoEventObjectSnapshot+ZoneChangeRecord(mirroringis_suspected), wire the capture sites, read it at both runtime matchers, and reclassifyFilterProp::Goadedback toSupported. Deferred because no card today prints a goad predicate on an event-subject filter under last-known-information — the reach gate is currently unreachable andgoaded_byclears on zone change, so the fail-closed classification is correct and complete for every current card.FilterProp::Renownedis NOT the same shape (correcting an earlier note):EventObjectSnapshotalready carriesis_renowned, soRenowned=Supportedis consistent at this reach gate; Renowned's only gap is on the separate zone-change-record path — a distinct, pre-existing concern, out of scope for this PR.