From 9ca191685fed09e0f1b0df6d2ede12c9c4852028 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 23:56:15 -0700 Subject: [PATCH 1/4] refactor(parser): emit temporal delayed triggers as native ir --- crates/engine/src/parser/oracle.rs | 5 +- crates/engine/src/parser/oracle_effect/mod.rs | 10 +- .../src/parser/oracle_ir/snapshot_tests.rs | 78 +++++++++ ...yed_trigger@full_throttle_temporal_ir.snap | 154 ++++++++++++++++ ...rigger@full_throttle_temporal_lowered.snap | 84 +++++++++ ...rigger@galvanic_iteration_temporal_ir.snap | 164 ++++++++++++++++++ ...r@galvanic_iteration_temporal_lowered.snap | 94 ++++++++++ ..._trigger@pact_of_negation_temporal_ir.snap | 156 +++++++++++++++++ ...ger@pact_of_negation_temporal_lowered.snap | 86 +++++++++ 9 files changed, 827 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap create mode 100644 crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 70d9eda5d1..8daa321626 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -5162,9 +5162,10 @@ pub(crate) fn parse_oracle_ir( // trigger-shaped temporal text through the effect parser before generic // trigger dispatch. if is_spell && has_trigger_prefix(&lower) { - if let Some(def) = try_parse_temporal_delayed_trigger_ability(&line, AbilityKind::Spell) + if let Some(ability) = + try_parse_temporal_delayed_trigger_ability(&line, AbilityKind::Spell) { - emitter.ability_at(item_line, def); + emitter.ability_ir_at(item_line, ability); i += 1; continue; } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index c374d359c8..0bb81c1fb3 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1653,14 +1653,20 @@ fn try_parse_copy_next_spell_when_cast(tp: TextPair) -> Option Option { +) -> Option { let lower = text.to_lowercase(); let tp = TextPair::new(text, &lower); let clause = try_parse_whenever_this_turn(tp) .or_else(|| try_parse_when_next_event(tp)) .or_else(|| try_parse_copy_next_spell_when_cast(tp)) .or_else(|| try_parse_at_next_phase_delayed_trigger(text, kind))?; - Some(ability_definition_from_clause(kind, clause)) + Some(AbilityIr { + source_text: text.to_string(), + body: EffectChainIr::single_clause(text, kind, clause, None, None, false), + shell: AbilityShellIr::default(), + die_results: vec![], + root_transforms: vec![], + }) } /// CR 603.7a: Pact-cycle instants ("At the beginning of your next upkeep, pay diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 0468eff710..9a822cd646 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -989,6 +989,84 @@ fn chandra_nalaar_minus_x_loyalty_is_ir_native() { insta::assert_json_snapshot!("chandra_nalaar_minus_x_loyalty_lowered", &lowered); } +// --------------------------------------------------------------------------- +// Spell temporal delayed triggers +// --------------------------------------------------------------------------- + +/// CR 603.7a-c: spell-only temporal trigger lines stay as native document IR +/// until the sole lowering seam. The Pact payload remains a deliberately +/// lowered boxed ability inside the outer delayed-trigger clause. +#[test] +fn temporal_delayed_trigger_spell_router_is_ir_native() { + let cases = [ + ( + "pact_of_negation_temporal_ir", + "pact_of_negation_temporal_lowered", + "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", + "Pact of Negation", + &["Instant"][..], + ), + ( + "full_throttle_temporal_ir", + "full_throttle_temporal_lowered", + "At the beginning of each combat this turn, untap all creatures that attacked this turn.", + "Full Throttle", + &["Sorcery"][..], + ), + ( + "galvanic_iteration_temporal_ir", + "galvanic_iteration_temporal_lowered", + "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", + "Galvanic Iteration", + &["Instant"][..], + ), + ]; + + for (ir_snapshot, lowered_snapshot, oracle_text, card_name, types) in cases { + let (ir, lowered) = parse_two_layer(oracle_text, card_name, types, &[]); + assert_eq!( + ir.items.len(), + 1, + "{card_name}: one source line emits one item" + ); + let OracleNodeIr::Spell(ability) = &ir.items[0].node else { + panic!("{card_name}: expected native temporal spell IR"); + }; + assert!(matches!( + &ability.body.clauses[0].parsed.effect, + Effect::CreateDelayedTrigger { .. } + )); + assert_eq!( + lowered.abilities.len(), + 1, + "{card_name}: one lowered ability" + ); + assert!(matches!( + lowered.abilities[0].effect.as_ref(), + Effect::CreateDelayedTrigger { .. } + )); + + if card_name == "Pact of Negation" { + let Effect::CreateDelayedTrigger { effect, .. } = + &ability.body.clauses[0].parsed.effect + else { + unreachable!("checked above"); + }; + assert!(matches!( + effect.kind, + crate::types::ability::AbilityKind::Spell + )); + } + + insta::with_settings!({ snapshot_suffix => ir_snapshot }, { + insta::assert_json_snapshot!("temporal_delayed_trigger", &ir); + }); + insta::with_settings!({ snapshot_suffix => lowered_snapshot }, { + insta::assert_json_snapshot!("temporal_delayed_trigger", &lowered); + }); + } +} + // --------------------------------------------------------------------------- // Equipment / Vehicles // --------------------------------------------------------------------------- diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap new file mode 100644 index 0000000000..f0fd67cc8a --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_ir.snap @@ -0,0 +1,154 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 87, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of each combat this turn, untap all creatures that attacked this turn." + }, + "node": { + "Spell": { + "source_text": "At the beginning of each combat this turn, untap all creatures that attacked this turn.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 87, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of each combat this turn, untap all creatures that attacked this turn." + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WheneverEvent", + "trigger": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + } + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "SetTapState", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "AttackedThisTurn" + } + ] + }, + "scope": { + "type": "All" + }, + "state": { + "type": "Untap" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "At the beginning of each combat this turn, untap all creatures that attacked this turn.", + "card_name": "Full Throttle" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap new file mode 100644 index 0000000000..b0cade1af7 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@full_throttle_temporal_lowered.snap @@ -0,0 +1,84 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Spell", + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WheneverEvent", + "trigger": { + "mode": "Phase", + "execute": null, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "BeginCombat", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + } + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "SetTapState", + "target": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": null, + "properties": [ + { + "type": "AttackedThisTurn" + } + ] + }, + "scope": { + "type": "All" + }, + "state": { + "type": "Untap" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap new file mode 100644 index 0000000000..97380972af --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_ir.snap @@ -0,0 +1,164 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 115, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy." + }, + "node": { + "Spell": { + "source_text": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 115, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy." + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WhenNextEvent", + "trigger": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Instant" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Sorcery" + ], + "controller": null, + "properties": [] + } + ] + }, + "origin": null, + "destination": null, + "trigger_zones": [], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": { + "type": "Controller" + }, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "or_trigger": null + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "CopySpell", + "target": { + "type": "TriggeringSource" + }, + "retarget": { + "type": "MayChooseNewTargets" + }, + "starting_loyalty_from_casualty_sacrifice": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", + "card_name": "Galvanic Iteration" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap new file mode 100644 index 0000000000..a643b06c9e --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@galvanic_iteration_temporal_lowered.snap @@ -0,0 +1,94 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Spell", + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "WhenNextEvent", + "trigger": { + "mode": "SpellCast", + "execute": null, + "valid_card": { + "type": "Or", + "filters": [ + { + "type": "Typed", + "type_filters": [ + "Instant" + ], + "controller": null, + "properties": [] + }, + { + "type": "Typed", + "type_filters": [ + "Sorcery" + ], + "controller": null, + "properties": [] + } + ] + }, + "origin": null, + "destination": null, + "trigger_zones": [], + "phase": null, + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": { + "type": "Controller" + }, + "valid_source": null, + "description": null, + "constraint": null, + "condition": null, + "batched": false + }, + "or_trigger": null + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "CopySpell", + "target": { + "type": "TriggeringSource" + }, + "retarget": { + "type": "MayChooseNewTargets" + }, + "starting_loyalty_from_casualty_sacrifice": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap new file mode 100644 index 0000000000..941b4c01bf --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_ir.snap @@ -0,0 +1,156 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 85, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game." + }, + "node": { + "Spell": { + "source_text": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 85, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game." + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "AtNextPhaseForPlayer", + "phase": "Upkeep", + "player": 0 + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "PayCost", + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Blue", + "Blue" + ], + "generic": 3 + } + }, + "payer": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "LoseTheGame", + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "Not", + "condition": { + "type": "EffectOutcome", + "signal": "OptionalEffectPerformed" + } + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Spell", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", + "card_name": "Pact of Negation" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap new file mode 100644 index 0000000000..818853fe57 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__temporal_delayed_trigger@pact_of_negation_temporal_lowered.snap @@ -0,0 +1,86 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Spell", + "effect": { + "type": "CreateDelayedTrigger", + "condition": { + "type": "AtNextPhaseForPlayer", + "phase": "Upkeep", + "player": 0 + }, + "effect": { + "kind": "Spell", + "effect": { + "type": "PayCost", + "cost": { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "Blue", + "Blue" + ], + "generic": 3 + } + }, + "payer": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "LoseTheGame", + "target": { + "type": "Controller" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "Not", + "condition": { + "type": "EffectOutcome", + "signal": "OptionalEffectPerformed" + } + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "uses_tracked_set": false + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} From 4c46fcfaeebe7b155122f4ab284b0066ba0e2673 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 30 Jul 2026 00:12:19 -0700 Subject: [PATCH 2/4] test(parser): cover whenever temporal trigger ir --- .../src/parser/oracle_ir/snapshot_tests.rs | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 9a822cd646..d3e5d2c6bc 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -998,31 +998,46 @@ fn chandra_nalaar_minus_x_loyalty_is_ir_native() { /// lowered boxed ability inside the outer delayed-trigger clause. #[test] fn temporal_delayed_trigger_spell_router_is_ir_native() { + // The three established grammar representatives remain the stable IR/lowered + // snapshot fixtures. The direct `Whenever … this turn` arm uses the same + // structural assertions without a fourth snapshot pair. let cases = [ ( - "pact_of_negation_temporal_ir", - "pact_of_negation_temporal_lowered", + None, + "Whenever you cast a creature spell this turn, draw a card.", + "Glimpse of Nature", + &["Sorcery"][..], + ), + ( + Some(( + "pact_of_negation_temporal_ir", + "pact_of_negation_temporal_lowered", + )), "At the beginning of your next upkeep, pay {3}{U}{U}. If you don't, you lose the game.", "Pact of Negation", &["Instant"][..], ), ( - "full_throttle_temporal_ir", - "full_throttle_temporal_lowered", + Some(( + "full_throttle_temporal_ir", + "full_throttle_temporal_lowered", + )), "At the beginning of each combat this turn, untap all creatures that attacked this turn.", "Full Throttle", &["Sorcery"][..], ), ( - "galvanic_iteration_temporal_ir", - "galvanic_iteration_temporal_lowered", + Some(( + "galvanic_iteration_temporal_ir", + "galvanic_iteration_temporal_lowered", + )), "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.", "Galvanic Iteration", &["Instant"][..], ), ]; - for (ir_snapshot, lowered_snapshot, oracle_text, card_name, types) in cases { + for (snapshots, oracle_text, card_name, types) in cases { let (ir, lowered) = parse_two_layer(oracle_text, card_name, types, &[]); assert_eq!( ir.items.len(), @@ -1058,12 +1073,14 @@ fn temporal_delayed_trigger_spell_router_is_ir_native() { )); } - insta::with_settings!({ snapshot_suffix => ir_snapshot }, { - insta::assert_json_snapshot!("temporal_delayed_trigger", &ir); - }); - insta::with_settings!({ snapshot_suffix => lowered_snapshot }, { - insta::assert_json_snapshot!("temporal_delayed_trigger", &lowered); - }); + if let Some((ir_snapshot, lowered_snapshot)) = snapshots { + insta::with_settings!({ snapshot_suffix => ir_snapshot }, { + insta::assert_json_snapshot!("temporal_delayed_trigger", &ir); + }); + insta::with_settings!({ snapshot_suffix => lowered_snapshot }, { + insta::assert_json_snapshot!("temporal_delayed_trigger", &lowered); + }); + } } } From e1c68903d902a817cf9a20475314ae3963a21a23 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 30 Jul 2026 00:15:37 -0700 Subject: [PATCH 3/4] test(parser): cover copy-next temporal ir helper --- .../engine/src/parser/oracle_effect/tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index f76c015079..07580c69a3 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -11127,6 +11127,24 @@ fn effect_tzaangor_copy_next_spell_when_cast() { ); } +/// CR 603.7: Tzaangor Shaman's copy-next clause is reachable from the +/// effect-chain parser, but not from the spell trigger-prefix router. Its +/// temporal helper must therefore construct native IR directly. +#[test] +fn temporal_copy_next_helper_emits_native_ir() { + let text = "Copy the next instant or sorcery spell you cast this turn when you cast it. \ + You may choose new targets for the copy."; + let ir = try_parse_temporal_delayed_trigger_ability(text, AbilityKind::Spell) + .expect("Tzaangor Shaman copy-next grammar must parse"); + + assert_eq!(ir.source_text, text); + assert_eq!(ir.body.clauses.len(), 1); + assert!(matches!( + &ir.body.clauses[0].parsed.effect, + Effect::CreateDelayedTrigger { .. } + )); +} + #[test] fn effect_each_merfolk_creature_you_control_explores_uses_explore_all() { let e = parse_effect("Each Merfolk creature you control explores"); From 6117828ffd8cea8829525f8352df0d847dd8f9eb Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 30 Jul 2026 00:38:55 -0700 Subject: [PATCH 4/4] test(parser): isolate copy-next temporal helper clause --- crates/engine/src/parser/oracle_effect/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 07580c69a3..10720f6b56 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -11132,8 +11132,10 @@ fn effect_tzaangor_copy_next_spell_when_cast() { /// temporal helper must therefore construct native IR directly. #[test] fn temporal_copy_next_helper_emits_native_ir() { - let text = "Copy the next instant or sorcery spell you cast this turn when you cast it. \ - You may choose new targets for the copy."; + // This is the first sentence of Tzaangor Shaman's AtomicCards Oracle text. + // `parse_effect_chain` splits the following retarget sentence before this + // helper sees its all-consuming copy-next grammar. + let text = "Copy the next instant or sorcery spell you cast this turn when you cast it"; let ir = try_parse_temporal_delayed_trigger_ability(text, AbilityKind::Spell) .expect("Tzaangor Shaman copy-next grammar must parse");