Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/engine/src/parser/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 8 additions & 2 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1653,14 +1653,20 @@ fn try_parse_copy_next_spell_when_cast(tp: TextPair) -> Option<ParsedEffectClaus
pub(crate) fn try_parse_temporal_delayed_trigger_ability(
text: &str,
kind: AbilityKind,
) -> Option<AbilityDefinition> {
) -> Option<AbilityIr> {
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
Expand Down
78 changes: 78 additions & 0 deletions crates/engine/src/parser/oracle_ir/snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"][..],
),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading
Loading