Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
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
18 changes: 18 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
95 changes: 95 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,101 @@ 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() {
// 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 = [
(
None,
"Whenever you cast a creature spell this turn, draw a card.",
"Glimpse of Nature",
&["Sorcery"][..],
),
Comment on lines +1001 to +1010

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Verify the fourth recognizer arm semantically.

The Glimpse of Nature case uses None for snapshots and therefore only reaches the generic CreateDelayedTrigger shape checks. A regression in its condition, draw payload, lowering, or arm dispatch could still pass. Add a two-layer snapshot pair or assert the expected condition and payload explicitly.

As per path instructions, test the reusable parser building block across its parameter range rather than leaving one recognizer arm with only a generic wrapper check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/parser/oracle_ir/snapshot_tests.rs` around lines 1001 -
1010, Strengthen the `Glimpse of Nature` case in the `cases` fixture within the
snapshot test by either supplying expected IR and lowered snapshot values or
explicitly asserting its `Whenever … this turn` condition and draw-card payload.
Ensure the test exercises the fourth recognizer arm’s semantic parsing and
lowering, not only the generic `CreateDelayedTrigger` shape.

Source: Path instructions

(
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"][..],
),
(
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"][..],
),
(
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"][..],
),
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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(),
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
));
}

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);
});
}
}
}

// ---------------------------------------------------------------------------
// 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"
}
Loading
Loading