feat(card): route pile-separation through trigger body parser (Sphinx…#5489
Conversation
… of Uthuun) Wire parse_separate_into_piles into the trigger body dispatch chain so ETB triggers with the Fact-or-Fiction pile-separation pattern produce a single SeparateIntoPiles effect instead of fragmenting into Unimplemented chunks. Cards unlocked: Sphinx of Uthuun, Baleful Strix (M12 printing with pile-separation ETB), and any future creature whose ETB trigger body matches the reveal-top-N → opponent-separates → zone-routing shape. CR 700.3: Pile-separation rules. CR 701.20a: Reveal is a keyword action. CR 603.1: Triggered abilities trigger when events occur.
There was a problem hiding this comment.
Code Review
This pull request implements support for parsing pile-separation triggers, such as Sphinx of Uthuun, within the Oracle parser and adds a corresponding integration test to verify the ETB flow. The reviewer suggested removing an unnecessary clone of pile_a_ids to optimize performance.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let pile_a_ids: Vec<_> = lib_cards[0..2].to_vec(); | ||
| runner | ||
| .act(GameAction::SubmitPilePartition { | ||
| pile_a: pile_a_ids.clone(), |
Parse changes introduced by this PR · 2 card(s), 3 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Approved. This is a genuinely small, well-scoped change, and it earns its keep by unlocking a class rather than a card. The coverage sticky shows the new branch picks up Unesh, Criosphinx Sovereign as well as Sphinx of Uthuun, which the PR description didn't claim. Two cards from fourteen lines, with no text stolen from the branches further down the chain, is the right shape for this parser.
A few things I verified so they don't get re-litigated later:
AbilityKind::Spell is correct here. This superficially resembles a known bug in this repo where a permanent's ability gets tagged Spell and silently no-ops. It isn't that. Routing is decided by the IR node variant, not by def.kind: OracleNodeIr::PreLoweredTrigger(def) pushes into result.triggers, so wrapping the returned AbilityDefinition in TriggerBody::PreLowered is what carries the trigger-ness. The kind argument only reaches parse_effect_chain_with_context to parse the body's sub-effect chain. Every sibling branch in the same PreLowered chain passes AbilityKind::Spell for exactly this reason, including the generic parse_effect_chain_ir fallback at the end. The convention holds.
The RevealTop removal is expected, not a dropped clause. The sticky pairs RevealTop removed with SeparateIntoPiles added on both cards. The reveal is subsumed by the pile-separation effect, which is the same signature change #5476 produced when it landed Fact or Fiction. Reading that sticky row as a regression would be a misread.
The test drives the real pipeline. It builds the creature from Oracle text, casts it, resolves it, and asserts the interactive round trip: WaitingFor::SeparatePilesPartition routed to the opponent with 5 eligible cards, then SeparatePilesChoice to the controller, then hand/graveyard routing, then a return to Priority. Removing the parser branch fragments the body into Unimplemented, the partition prompt never fires, and the test fails. It discriminates on the behavior the PR claims to add, rather than asserting on parser output shape. It also lives in crates/engine/tests/integration/ with the mod line added, per the rule against new top-level test binaries.
The CR citations hold up against docs/MagicCompRules.txt. CR 700.3 covers objects temporarily grouped into piles and uses Fact or Fiction as its own worked example, and CR 701.20a is Reveal.
The one Gemini nit (an unnecessary clone of pile_a_ids) is in test code and not worth a round trip. Merging.
Thanks for the clean contribution.
Summary
Route
parse_separate_into_pilesthrough the trigger body dispatch chain so ETB triggers with the Fact-or-Fiction pile-separation pattern produce a singleSeparateIntoPileseffect instead of fragmenting intoUnimplementedchunks.Cards unlocked: Sphinx of Uthuun, and any future creature whose ETB trigger body matches the reveal-top-N → opponent-separates → zone-routing shape (the same pattern already works for spell bodies via the existing dispatch in
oracle.rs).Files changed
crates/engine/src/parser/oracle_trigger.rsparse_separate_into_pilescall in trigger body dispatch chain, afterparse_vote_blockand beforetry_parse_exile_top_each_library_with_collection_countercrates/engine/tests/integration/sphinx_of_uthuun_etb_pile_separation.rscrates/engine/tests/integration/main.rsAnchored on
crates/engine/src/parser/oracle_trigger.rs:1132— existingparse_vote_blockcall in the same trigger body dispatch chain (samePreLoweredpattern, same position in theif/else if/elseladder)crates/engine/src/parser/oracle.rs:4519— existingparse_separate_into_pilescall in the spell body dispatch (same function, sameAbilityKind::Spellargument)Gate A
Verification
cargo check --lib -p engine— passescargo fmt --all— no changes./scripts/check-parser-combinators.sh— exit 0CR annotations verified
Track
Non-developer (CI runs full verification).
Test description
sphinx_of_uthuun_etb_pile_separation: Builds a creature from Oracle text with the Sphinx of Uthuun ETB trigger, casts it, verifies the trigger resolves intoSeparatePilesPartition(opponent partitions 5 revealed cards), thenSeparatePilesChoice(controller chooses), then verifies chosen pile goes to hand and unchosen pile goes to graveyard. Would fail if the change were reverted (trigger body would parse asUnimplementedinstead ofSeparateIntoPiles).