Add Plargg and Nassari - #55
Conversation
…igger
The middle sentence "An opponent chooses a nonland card exiled this
way" was Unimplemented{choose}: the typed exiled-this-way anaphor fell
through honestly, the "an opponent" subject lost the chooser, and the
ChooseFromZoneChoice continuation bound the pick (not the complement)
onto the follow-up cast.
- imperative.rs: typed "exiled this way" arm of
try_parse_choose_exiled_anaphor lowers to the Koh-shape
FromZone { Exile, AllOwners, And { Typed, ExiledBySource } }
(CR 607.2a linkage; CR 400.1 shared-zone scan); chooser prefix
alternation ("an opponent chooses" -> Chooser::Opponent, CR 608.2d);
non<type> qualifier support (CR 205.2b).
- mod.rs: lower_subject_predicate_ast rebinds the bare "an opponent"
subject onto ChooseFromZone's chooser before the honesty gate.
- engine_resolution_choices.rs: an "other cards" cast continuation
(ExiledBySource + FilterProp::Another) receives the UNCHOSEN
complement of the pick (CR 608.2c); chosen-consumer continuations
(End-Blaze Epiphany) are untouched.
Tests: two anaphor unit tests + untyped-referent regression guard
(imperative.rs), full three-sentence chain shape (tests.rs), and a
three-player runtime test driving the opponent's pick and asserting
zero-cost permissions land only on the other nonland hits
(exile_from_top_until.rs).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0dd2600f54
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| filter: TargetFilter::And { | ||
| filters: vec![ | ||
| TargetFilter::Typed(TypedFilter::new(tf)), | ||
| TargetFilter::ExiledBySource, |
There was a problem hiding this comment.
Bind typed exiled-this-way choices to the current set
When a typed exiled this way choice is lowered to a durable ExiledBySource scan, it is no longer scoped to the objects exiled by this resolving instruction. For example, a later Plargg upkeep can offer nonland cards that the same source exiled on previous upkeeps and left in exile; for an Author of Shadows-style chain, a preceding tracked-set producer can also cause ChooseFromZone to read the published set before applying this new nonland filter. This needs to bind a current-resolution filtered set (or filter tracked-set candidates) rather than the source's lifetime linked-exile pool.
AGENTS.md reference: AGENTS.md:L14-L14
Useful? React with 👍 / 👎.
| cont.chain.targets = | ||
| unchosen.iter().map(|&id| TargetRef::Object(id)).collect(); |
There was a problem hiding this comment.
Enforce the two-spell cap before granting permissions
In a four-player Plargg game, the opponent chooses one nonland hit and this branch then forwards every unchosen hit as CastFromZone targets; CastFromZone stamps a separate zero-cost permission on each target and has no count field, so the controller can cast all three remaining hits even though the card says "up to two spells." The continuation needs to carry/enforce that limit before granting permissions instead of binding the full unchosen complement.
AGENTS.md reference: AGENTS.md:L14-L14
Useful? React with 👍 / 👎.
Parse changes introduced by this PR · 3 card(s), 3 signature(s) (baseline: main
|
…is-way anaphor 'target opponent chooses' must bind the chooser to the chosen target slot; collapsing it into Chooser::Opponent loses target identity, and no printed card pairs the targeted form with an 'exiled this way' anaphor. Accept only 'an opponent chooses' and let the targeted form fall through honestly. Addresses the CodeRabbit review finding on PR phase-rs#6320.
…st window for 'up to N spells ... exiled this way' Addresses the CHANGES_REQUESTED review on phase-rs#6320: HIGH-1 (multiplayer chooser): resolving a ChooseFromZone with chooser: Opponent and 2+ live opponents now pauses on the new typed WaitingFor::ChooseFromZoneOpponentChooser so the CONTROLLER picks which opponent makes the choice (Plargg release notes), mirroring the ClashChooseOpponent pattern. With exactly one live opponent the picker is skipped. Registered across engine, ai_support, phase-ai, scenario, action stable order, manabrew-compat, and the server payload guard. HIGH-2 (up-to-two bound): 'cast up to N spells from among the [other] cards exiled this way without paying their mana costs' no longer lowers to CastFromZone permission grants (no shared budget); it now routes to Effect::FreeCastFromZones - the during-resolution counted window (Invoke Calamity machinery) - matching ruling 2021-04-16 that the spells are cast during resolution. FreeCastFromZones gains a Zone::Exile arm scoped to the resolving source's exile links, a cast-mode land guard, and source threading through the CastOffer window and re-offer loop. 'the other cards' rewrites Another -> Not(InTrackedSet(0)) over the prior choose's published picks. The unchosen-complement binding in the ChooseFromZoneChoice handler is reverted (superseded by the tracked-set complement filter). Tests: 4-player runtime test drives all three pauses (picker -> opponent zone choice -> capped FreeCastWindow with the unchosen-only pool); 2-player regression asserts the picker pause is skipped; parser chain test updated to the FreeCastFromZones tail.
|
CI re-check: maintainer-review fixes pushed in 0559e93 (opponent-picker pause + counted free-cast window). |
…nent-chooser step - client: mirror WaitingFor::ChooseFromZoneOpponentChooser and GameAction::ChooseZoneOpponentChooser in the adapter unions, register the variant in HANDLED_WAITING_FOR_TYPES, and wire ZoneOpponentChooserModal (ClashOpponentModal pattern) into GamePage with i18n keys in all locales - engine: add the new 'source' field to the FreeCastWindow test constructor in visibility.rs (E0063 on both test shards) - engine: apply rustfmt to the two flagged sites in exile_from_top_until.rs
…resolution's exile batch Review round 2 (matthewevans, CHANGES_REQUESTED) + shard-2 CI defect on c88ad0e: - Thread THIS resolution's concrete member pool (the choose's full offered pool, derived from the chain tracked set the exile clause published) through the ChooseFromZoneChoice -> FreeCastFromZones continuation as object targets; eligible_candidates intersects the exile scan with it BEFORE the Not(InTrackedSet) chosen-card exclusion (CR 607.2a), so a linked nonland card left in exile by a previous upkeep is never re-offered. Carried on CastOfferKind::FreeCastWindow and FreeCastOfferRemaining for the re-offer loop; redacted like candidates for non-controller viewers (CR 400.2). - Fix the c88ad0e shard-2 failure (window skipped, Priority instead of CastOffer): chain_references_tracked_set could not see the chosen-card exclusion nested as FilterProp::Not { InTrackedSet } inside the typed leg of an And, so the chosen card was never published as the fresh chain set and the InTrackedSet(0) sentinel stayed bound to the exile-batch set, emptying the window. Add a property-level membership walker (filter_properties_reference_tracked_membership) to the FreeCastFromZones detection arm (CR 608.2c). - Add the requested two-upkeep regression (plargg_second_resolution_excludes_previous_resolutions_leftovers): upkeep 1 declines the window leaving a linked leftover in exile; upkeep 2 asserts both the opponent's choice pool and the free-cast window exclude it. - Extend the FreeCastWindow visibility test to pin member_pool redaction.
|
Review round 2 addressed in cb332a5 (PR body updated with full details). HIGH — resolution-scoped "exiled this way": implemented as a concrete member-pool thread through the choose → free-cast continuation, exactly as suggested. The HIGH — frontend wiring: landed in c88ad0e (crossed with the review): adapter Shard-2 failure on c88ad0e (window skipped, MED — parse-diff reconciliation: the three previously unclaimed cards (Forgotten Lore, Shrouded Lore, Search for Survivors) are now claimed and justified in the PR body's "Claimed parse impact": they ride the subject-layer chooser rebind (seam 2) — the same delegated-chooser grammar family ("an opponent chooses a card in your graveyard") — with their remaining clauses parsing via pre-existing arms. |
…estore, targeted-chooser honesty gate - Register WaitingFor::ChooseFromZoneOpponentChooser in waits_for_resolution_choice so the picker pause parks the FreeCastFromZones continuation instead of resolving it inline before any pick exists (CI shard-2: Priority instead of CastOffer on the 4-player path). - Restore priority (set_priority) before draining the parked continuation on the picker answer arm's empty-pool path, mirroring ChooseClashOpponent (review HIGH). - Gate the counted free-cast reroute on constraint.is_none() so a parsed cast constraint is never silently dropped (review HIGH). - Gate the subject-layer chooser rebind on subject.target.is_none(): 'target opponent chooses …' (Forgotten/Shrouded Lore) binds the chooser to a chosen target slot Chooser::Opponent cannot represent — keep the honest fall-through (review MED); pin both directions with parser tests, claiming Search for Survivors' untargeted form. - Render the engine-supplied candidate order in ZoneOpponentChooserModal instead of re-sorting by client seatOrder (review MED); pin with a shuffled-input test. - Register local.zone-opponent-chooser-unsupported in manabrew-compat's capability registry, 18→19 entries (review MED). - Two-upkeep regression also asserts the declined card stays exile-linked (resolution scoping, not link pruning).
|
Round-3 review addressed in 92c56e7 (all five items, plus the outstanding shard-2 CI failure root-caused on this head). HIGH — empty member-pool continuation drops priority: fixed. The HIGH — counted free-cast arm drops a parsed cast constraint: fixed. The reroute to MED — modal must render engine order: fixed. MED — capability registry: fixed. MED — parse-diff reconciliation with tests: resolved by tightening the subject-layer rebind rather than claiming all three cards. "Target opponent chooses …" (Forgotten Lore, Shrouded Lore) lowers "target opponent" to the same bare opponent filter as "an opponent", but the subject carries Shard-2 CI failure (4-player test, (CodeRabbit nitpick): the two-upkeep regression additionally asserts the declined card remains exile-linked to the source after upkeep 1, pinning that upkeep 2's exclusion comes from resolution scoping rather than link pruning. |
Summary
Adds engine support for Plargg and Nassari.
Closes the previously-unsupported
choosegap on the upkeep trigger's middle sentence — "An opponent chooses a nonland card exiled this way." — and, per review, makes the full trigger rules-correct end-to-end:try_parse_choose_exiled_anaphor's "exiled this way" referent only supported the untyped chain-tracked-set form ("choose a card exiled this way") and fell through honestly on a typed qualifier. The typed form ("a nonland card exiled this way") now lowers to the same linked-exile shape as the Koh, the Face Stealer "exiled with ~" arm:ChooseFromZone { zone: Exile, zone_owner: AllOwners, filter: And { Typed, ExiledBySource } }(CR 607.2a linkage; CR 400.1 shared-zone scan). The card-type qualifier now also accepts negatednon<type>forms (CR 205.2b), and an "an opponent chooses" prefix (→Chooser::Opponent, CR 608.2d) mirrorsparse_choose_anaphoric's existing convention. Per review, the speculative"target opponent chooses"tag was dropped in 76cf4d1 — no printed card pairs it with an exiled-this-way anaphor, and collapsing a targeted chooser to the generic opponent would be wrong.strip_subject_clausepeels "an opponent" and deconjugates the predicate to "choose …", losing the chooser.lower_subject_predicate_astnow rebinds the bare single-opponent subject ontoChooseFromZone'schooserbefore the honesty gate; typed or property-carrying subjects still fall through toEffect::unimplemented. Per review 3, the rebind is additionally gated onsubject.target.is_none()— "target opponent chooses …" (Forgotten Lore, Shrouded Lore) binds the chooser to a chosen target slot thatChooser::Opponentcannot represent, so the targeted form keeps its honest fall-through (see Claimed parse impact).CastFromZonepermission grants (per-object, no shared budget — the printed bound was silently dropped). It now routes toEffect::FreeCastFromZones, the counted during-resolution free-cast window (Invoke Calamity machinery), withcount: N— matching ruling 2021-04-16 that Plargg's spells are cast during the ability's resolution.FreeCastFromZonesgains aZone::Exilearm scoped to the resolving source's exile links (CR 607.2a) plus a cast-mode land guard (CR 305.1), and the source is threaded throughCastOfferKind::FreeCastWindowand the re-offer loop. "The other cards" rewritesFilterProp::Another → Not(InTrackedSet(0))over the prior choose's published picks (CR 608.2c), mirroringrewrite_filter_prop_another_to_tracked_set; uncounted forms (Etali, Primal Conqueror's "cast any number") keep their existing lowering.ChooseFromZonewithchooser: Opponentand two or more live opponents now pauses on a new typed resolution-time step —WaitingFor::ChooseFromZoneOpponentChooser/GameAction::ChooseZoneOpponentChooser— so the controller picks which opponent makes the choice, per the card's release notes ("you choose which opponent gets to choose"). Mirrors theClashChooseOpponentpattern end-to-end (pause raise, resume handler, AI candidate enumeration, decision-kind grouping, stable action ordering, scenario naming, manabrew-compat conversion, server payload guard, and the frontend: adapter unions, handler registry,ZoneOpponentChooserModalmounted in GamePage, i18n keys in all seven locales — c88ad0e). With exactly one live opponent the picker is skipped and the choice goes directly to that opponent.resolve_candidate_cardsreturned tracked-set-derived candidate pools raw, ignoring the effect's own card filter — the player-scope completion publishes every card the exile clause moved (lands included) as the chain tracked set, so the opponent was offered the lands too. A typed filter (Some(...)) now narrows tracked-set and explicit-target pools throughmatches_target_filter(CR 608.2d);filter: Nonekeeps the raw set, so the untyped "choose one of them" tracked-set anaphors are unchanged. Surfaced by the runtime regression test on this PR's first CI run.TargetFilter::ExiledBySourcereads the source's complete live linked-exile ledger, so a linked nonland card left in exile by a previous upkeep (declined window) would wrongly re-enter the next resolution's free-cast offer. TheChooseFromZoneChoiceanswer handler now forwards the choose's full offered pool — THIS resolution's typed exile batch, derived from the chain tracked set the exile clause published — as theFreeCastFromZonescontinuation's object targets; the resolver reads it as a concretemember_pool, andeligible_candidatesintersects the exile-zone scan with it BEFORE the filter'sNot(InTrackedSet)chosen-card exclusion (CR 607.2a). The pool is carried onCastOfferKind::FreeCastWindowandResolutionCastSuccessAction::FreeCastOfferRemainingso the re-offer loop stays confined too, and is redacted likecandidatesfor non-controller viewers (CR 400.2). Empty means no batch restriction — Invoke Calamity's chooseless graveyard/hand window is untouched, as is Etali's uncounted permission-grant path. The same seam fixes a defect the c88ad0e CI run exposed (CI Failures item 5):chain_references_tracked_setcould not see the chosen-card exclusion —FilterProp::Not { InTrackedSet }nested inside the typed leg of anAnd— so the choose head never published the chosen card as the fresh chain set, theInTrackedSet(0)sentinel stayed bound to the exile-batch set, andNotover the batch emptied the window entirely. A property-level membership walker (filter_properties_reference_tracked_membership, mirroring the existing quantity walker) now feeds theFreeCastFromZonesdetection arm (CR 608.2c).An earlier interim seam (binding the unchosen complement in the
ChooseFromZoneChoicehandler) is reverted in 0559e93 — the tracked-set complement filter in seam 3 supersedes it structurally, so continuation semantics for every other card class are untouched.Files changed
CR references
non<type>card-type qualifier)Implementation method (required)
Method: /engine-implementer
Track
Non-developer
LLM
Model: manus-1.5
Thinking: high
Verification
./scripts/check-parser-combinators.sh upstream/main—Gate G PASS+Gate A PASSon the committed round-3 head92c56e7(the rebind gate and constraint gate are new conditionals inside existing arms — no new combinator dispatch).pnpm run type-check— clean;eslinton the touched files — 0 errors;vitest run— full suite executed (262 files / 2236 tests): 255 files passed outright and the 7 sandbox-timeout files (unrelated menu/lobby/deck suites hitting the 5s cap while a parallel Rust compile saturated the VM) all pass in isolation, 63/63.rustfmt --check— clean on every Rust file this branch touches (verified locally on the round-3 head).cargo test -p engine(including the nine new tests below),cargo clippy-strict,cargo coverage, andcargo semantic-auditare delegated to the CI-owned checks on this PR — the ephemeral sandbox was OOM-reset during local compile attempts, so full local test execution could not complete.parse_choose_nonland_card_exiled_this_way,parse_opponent_chooses_nonland_card_exiled_this_way,parse_choose_card_exiled_this_way_still_tracked_set(imperative.rs),plargg_and_nassari_full_trigger_chain_choose_then_cast_others(tests.rs — asserts theFreeCastFromZones{count: 2}tail with theNot(InTrackedSet)rewrite),search_for_survivors_opponent_chooses_at_random_in_your_graveyardandtarget_opponent_chooses_in_your_graveyard_keeps_honest_fall_through(tests.rs — round-3 parse-impact pins),plargg_opponent_chooses_nonland_hit_then_cast_pool_is_the_other_hits(exile_from_top_until.rs — 4-player,apply()-driven through all three pauses: opponent-picker → zone choice → cappedFreeCastWindowwith the unchosen-only pool andremaining_casts == 2),plargg_two_player_skips_the_opponent_picker_pause(2-player regression: no picker pause with a single opponent),plargg_second_resolution_excludes_previous_resolutions_leftovers(exile_from_top_until.rs — review-2 two-upkeep regression: the full chain resolves twice against the same source; upkeep 1's window is declined leaving a linked nonland card in exile — and asserted still exile-linked — and upkeep 2's choice pool AND free-cast window are asserted to exclude it), andZoneOpponentChooserModal.test.tsx(frontend: registry registration,ChooseZoneOpponentChooserdispatch, and engine-supplied render order).Gate A
Gate A PASS head=92c56e77aa506a228ce0a53b74e2f062c2acc5b2 base=209324a701dce9d3ea183f79d9380032d00706ea (Gate G PASS on the same run)
Anchored on
FromZone { Exile, AllOwners, And { Typed, ExiledBySource } }lowering the typed "exiled this way" referent now reuses (CR 607.2a + CR 400.1).ClashChooseOpponent: the existing typed resolution-time "controller picks an opponent" pause/resume seam the newChooseFromZoneOpponentChooserstep mirrors across every registration site.count" stop-early offer loop the Plargg cast tail now rides, extended with the exile-links zone arm.Final review-impl
Final review-impl PASS head=92c56e7 (round-3 review fixes + picker continuation parking over cb332a5)
if let Ok(tag)blocks, no hand-builtEffect::Unimplementedliterals in the diff.choose_from_zone's single chooser-resolution authority with its resume in the one interactive-choice handler; the counted-cast reroute sits intry_parse_cast_effectahead of the uncounted arm it specializes.resolve_ability_chain→WaitingFor::ChooseFromZoneOpponentChooser→apply(ChooseZoneOpponentChooser)→WaitingFor::ChooseFromZoneChoice→apply(SelectCards)→WaitingFor::CastOffer{FreeCastWindow}) and fails if the picker pause, the chooser delegation, the typed-anaphor filter, the cap, or the complement rewrite is reverted. The 2-player test pins the no-picker fast path. The untyped-referent regression guard pins End-Blaze-style impulse reductions to the chain tracked set.Claimed parse impact
strip_subject_clausepeels the bare untargeted "an opponent" subject, the predicate lowers through the existing random graveyard choose arm, and the rebind restoresChooser::Opponent— hence its+ ChooseFromZone (count=1, zone=graveyard)parse-diff signature. Pinned bysearch_for_survivors_opponent_chooses_at_random_in_your_graveyard(tests.rs), asserting chooser, zone, andCardSelectionMode::Random.parse_targetlowers "target opponent" to the same bare opponent filter as "an opponent", so the rebind originally caught them too — but the subject carriestarget: Some(_): the chooser is a chosen target slot, andChooser::Opponentcannot represent that binding (the controller would wrongly pick the chooser at resolution). The rebind is now gated onsubject.target.is_none(), restoring both cards to the honestEffect::Unimplementedfall-through — the same deliberate rejection the exiled-this-way anaphor keeps for its targeted form (76cf4d1). Pinned bytarget_opponent_chooses_in_your_graveyard_keeps_honest_fall_through(tests.rs). The CI parse diff on this head should list exactly three cards.Validation Failures
None.
CI Failures
Six red runs during iteration, each root-caused and fixed on this branch.
Rust lint— two rustfmt diffs in test/handler code introduced after a sandbox reset dropped the localcargo fmtpass. Fixed in 5578e01 by applying the exact rustfmt output.Rust tests (both shards)— E0433:WaitingFormissing from the runtime test's local import list. Fixed in de0e127.Rust tests (shard 2/2)— the new runtime regression test failed: the opponent's candidate pool contained all six exiled cards (lands included) becauseresolve_candidate_cardsignored the effect filter for chain-tracked-set pools. Fixed in 7aaadbd (Summary seam 5); shard 1, Card data, and all other checks were already green on the same run, and no pre-existing test changed behavior.0559e93— three failures, all fixed in c88ad0e: (a)Rust tests (both shards)— E0063: the pre-existingFreeCastWindowvisibility test constructor was missing the newsourcefield; (b)Rust lint— two rustfmt diffs in the new runtime tests; (c)Frontend— the boundary-guardrail tests require every new engineWaitingFor/GameActionvariant to be mirrored in the client unions with a registered UI handler, soZoneOpponentChooserModalwas wired end-to-end (adapter types, registry, GamePage mount, i18n keys in all locales, modal test).c88ad0e,Rust tests (shard 2/2)— the 4-player runtime test ran for the first time (both prior shards had died at the E0063 compile error) and exposed a real defect: after the opponent's pick, the free-cast window was silently skipped (Priorityinstead ofCastOffer). Root cause:chain_references_tracked_setwalks whole-filterTrackedSetvariants but Plargg's chosen-card exclusion isFilterProp::Not { InTrackedSet }nested inside the typed leg of anAnd, so the choose head never published the chosen card as the fresh chain set; theInTrackedSet(0)sentinel stayed bound to the exile-batch set andNotover the batch emptied the window. Fixed by adding a property-level membership walker (filter_properties_reference_tracked_membership, mirroring the existing quantity walker) to theFreeCastFromZonesdetection arm.cb332a5,Rust tests (shard 2/2)— the 4-player test still failed with the same symptom (Priorityinstead ofCastOffer) while the two-upkeep and 2-player regressions passed, isolating the defect to the picker path:WaitingFor::ChooseFromZoneOpponentChooserwas never registered inwaits_for_resolution_choice, so when the picker pause was raised the chain machinery treated the choose head as not paused and resolved theFreeCastFromZonessub-ability inline — before any pick existed — leaving nothing parked for the answer handlers to drive. Fixed by registering the variant alongsideChooseFromZoneChoice; the picker now parks the free-cast continuation exactly like the direct 2-player path (item 1 of the round-3 review response hardens the same handler's empty-pool drain).Review response (matthewevans, round 1, CHANGES_REQUESTED)
ChooseFromZoneOpponentChooserresolution step (Summary seam 4); the 4-player test drives the pick and the 2-player test pins the skip path.FreeCastFromZonesreroute (Summary seam 3); the cap is asserted (remaining_casts == 2) in the 4-player test and the parser chain test."target opponent chooses"tag was dropped from the new prefix; no printed card pairs it with an exiled-this-way anaphor.Review response (matthewevans, round 2, CHANGES_REQUESTED)
waitingForRegistryregistration,ZoneOpponentChooserModalmounted in GamePage, i18n keys in all seven locales, and the modal dispatch test. Frontend CI is green on that head's checks locally (guardrails + parity suites).eligible_candidatesbefore theNot(InTrackedSet)chosen-card exclusion; the re-offer loop carries it viaFreeCastOfferRemaining, and the visibility layer redacts the pool to placeholders exactly likecandidates(CR 400.2, asserted in the extended visibility test). The requested two-upkeep regression (plargg_second_resolution_excludes_previous_resolutions_leftovers) resolves the full chain twice: upkeep 1's window is declined leaving a linked nonland card in exile, and upkeep 2 asserts both the opponent's choice pool and the free-cast window exclude the stale leftover while offering exactly the new batch. The same round also fixes the shard-2 defect CI exposed on c88ad0e (CI Failures item 5): the tracked-set consumption detection now sees the property-levelNot(InTrackedSet)exclusion, so the chosen card is published as the fresh chain set and the window opens over the correct pool.Review response (matthewevans, round 3, CHANGES_REQUESTED)
ChooseZoneOpponentChooseranswer arm's empty-pool drain discardedplayerand calledresume_pending_continuation_if_prioritywhilewaiting_forwas still the picker pause — the drain is gated onWaitingFor::Priority, so the state wedged. The arm now bindsplayerand callsset_priority(state, player)before resuming, mirroring the adjacentChooseClashOpponentarm's exact sequence.try_parse_counted_free_cast_from_exiled_this_way's caller now gates the reroute onconstraint.is_none()—Effect::FreeCastFromZoneshas no constraint channel, so a constrained counted form (none printed today) falls through to the strict handling instead of silently discarding the restriction.ZoneOpponentChooserModalno longer re-sorts candidates by clientseatOrder; it renders the engine-supplied candidate order verbatim (the engine already emits opponents in turn order from the controller — that game ordering belongs in the engine). The modal test now pins the render order against a deliberately shuffled input.local.zone-opponent-chooser-unsupportedis registered in manabrew-compat'sUNSUPPORTED_PROTOCOL_CAPABILITIES(18 → 19 entries) with the well-formedness test updated.subject.target.is_none()gate plus two pinning parser tests — Search for Survivors claimed, the Lores deliberately reverted (see Claimed parse impact). Expected parse diff on this head: exactly three cards.