From b104f5a3a506fb1cab44b4262ea51d2db8146b4a Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:38:41 +0200 Subject: [PATCH 1/7] fix(parser): keep a mandatory instruction under a reflexive mode list (#7528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "exile another card from a graveyard. When you do, choose one — …" lost its exile. The triggered-modal dispatch keyed the whole reflexive decision on a literal ", you may ", so a mandatory parent classified as "plain triggered modal" and the mode list replaced the trigger's parsed body outright. Cemetery Desecrator exiled nothing in any game state, which left X at 0 and both of its modes inert. CR 118.12 prints the parent two ways — "[Do something]. If [a player] [does] …" and "[A player] may [do something]. If [that player] [does] …" — and CR 603.12's connector reads the same in both. The marker says how the instruction is OFFERED, not whether a reflexive exists, so the connector is what must decide. `classify_reflexive_modal_parent` is now the single authority for that question, and `ReflexivePaymentIr` is parameterized over its parent (`MayPay` / `Mandatory`) instead of assuming a payment. The mandatory arm reuses the chain the trigger parser already lowered — the same path every non-modal reflexive takes today (Bone Rattler, Diregraf Horde, Back for More, Foray of Orcs, Dream Eater) — rather than parsing the same words a second time. Counter-probe: with the `Mandatory` arm removed, `the_mandatory_instruction_before_a_mode_list_is_performed` reads `left: (0, 1)` against `right: (1, 0)` — the card exiles nothing and the fodder stays in the graveyard. Class: 35,795 cards in card-data.json; 410 carry "when you do"; on 7 the connector introduces a mode list. Five have an optional parent and were already correct — Caesar, Legion's Emperor still lowers unchanged. Remaining gaps, both deliberate: - Dialogue Tree, the other mandatory member, is a SORCERY. Its whole line is routed through the TRIGGERED-modal path, so it needs a parent instruction on the non-triggered modal block as well; it is unchanged here. - CR 603.12 suppression is untouched. With every graveyard empty the instruction now runs and exiles nothing, but the reflexive is still created and still asks for a mode, because the engine keeps no record that a mandatory instruction did nothing. That is issue #7511's remaining half. Co-Authored-By: Claude Opus 5 --- crates/engine/src/parser/oracle_ir/ast.rs | 42 ++- crates/engine/src/parser/oracle_ir/trigger.rs | 44 +++- crates/engine/src/parser/oracle_modal.rs | 239 +++++++++++++++--- crates/engine/src/parser/oracle_trigger.rs | 60 +++-- crates/engine/tests/integration/main.rs | 1 + .../mandatory_reflexive_modal_parent.rs | 173 +++++++++++++ 6 files changed, 492 insertions(+), 67 deletions(-) create mode 100644 crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 0153e39645..29e5f56416 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1992,6 +1992,31 @@ fn normalize_play_from_exile_duration(duration: Duration) -> Duration { // --- Modal types (moved from oracle_modal.rs) --- +/// CR 118.12 + CR 603.12: The printed instruction a triggered modal's reflexive +/// connector rides on — `", . When you do, choose …"`. +/// +/// CR 118.12 prints the instruction two ways, and the connector reads the same +/// in both: `"[A player] may [do something]. If [that player] [does] …"` and +/// the bare `"[Do something]. If [a player] [does] …"`. Classifying on the +/// `"you may "` marker alone therefore answers the wrong question — the marker +/// says how the instruction is OFFERED, not whether a reflexive exists. The +/// connector is what decides that, so this type keeps the two apart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) enum ReflexiveModalParent { + /// `"…, you may . When you do, choose …"` — a declinable + /// resolution-time payment (Caesar, Legion's Emperor). Carries the printed + /// cost text with the `"you may "` marker and the connector stripped; + /// `trigger_line` is reduced to the bare trigger condition alongside it. + MayPay(String), + /// `"…, . When you do, choose …"` — a mandatory instruction + /// (Cemetery Desecrator, Dialogue Tree). No text is carried: the + /// instruction stays in `trigger_line`, where the ordinary trigger parser + /// lowers it as it already does for every non-modal reflexive (Bone + /// Rattler, Diregraf Horde). Lowering then attaches the modal as that + /// chain's `WhenYouDo` sub instead of replacing it. + Mandatory, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) enum OracleBlockAst { ActivatedModal { @@ -2008,15 +2033,14 @@ pub(crate) enum OracleBlockAst { trigger_line: String, header: ModalHeaderAst, modes: Vec, - /// CR 603.12 + CR 700.2b: When the trigger gates its modal choice behind - /// an optional reflexive cost ("Whenever you attack, you may sacrifice - /// another creature. When you do, choose ..."), this holds the cost - /// effect text (e.g. "Sacrifice another creature"). The lowering builds - /// an `Effect::Sacrifice { optional }` whose `WhenYouDo` sub_ability - /// carries the modal, so the modes fire only after the cost is paid. - /// `None` for a plain triggered modal (Pip-Boy), where the modal attaches - /// directly as the trigger's execute. - optional_cost: Option, + /// CR 603.12 + CR 700.2b: How the modal choice is introduced. + /// + /// `None` is a plain triggered modal (Pip-Boy 3000), where the modal + /// attaches directly as the trigger's execute. Anything else means a + /// reflexive connector stands between the trigger and the mode list, + /// and the modal must ride on the printed instruction before it — + /// see `ReflexiveModalParent`. + reflexive_parent: Option, }, /// CR 614.12c + CR 607.2d: "As [this permanent] enters, choose or /// . \n • . \n • ." The diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index cd1d8ce3e6..40911c08aa 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -97,7 +97,7 @@ impl TriggerIr { pub(crate) fn has_terminal_roll_die(&self) -> bool { let chain = match &self.body { Some(TriggerBody::EffectChain(chain)) => chain, - Some(TriggerBody::ReflexivePayment(reflexive)) => &reflexive.effect_chain, + Some(TriggerBody::Reflexive(reflexive)) => &reflexive.effect_chain, Some(TriggerBody::Modal(_)) | Some(TriggerBody::Vote(_)) | Some(TriggerBody::Pile(_)) @@ -116,9 +116,9 @@ impl TriggerIr { pub(crate) enum TriggerBody { /// Normal effect chain — lowering calls `lower_effect_chain_ir`. EffectChain(EffectChainIr), - /// CR 118.12 + CR 603.12: A resolution-time optional cost and the - /// reflexive effect that follows when the player pays it. - ReflexivePayment(Box), + /// CR 118.12 + CR 603.12: A printed parent instruction and the reflexive + /// effect that follows when its event occurred. + Reflexive(Box), /// CR 700.2: An inline modal's marker clause and its already-lowered mode /// bodies. The marker still flows through ordinary trigger-chain lowering; /// this payload carries the modal metadata no clause can represent. @@ -130,14 +130,44 @@ pub(crate) enum TriggerBody { Pile(Box), } +/// CR 603.12: A reflexive "when you do" body together with the printed parent +/// instruction it rides on. +/// +/// `parent` is the axis that used to be assumed rather than represented: this +/// node only existed for the `"you may . When you do"` surface, so a +/// MANDATORY parent had nowhere to live. CR 118.12 prints both — "[Do +/// something]. If [a player] [does] …" and "[A player] may [do something]. If +/// [that player] [does] …" — and CR 603.12 gates the reflexive on the event +/// either way. Keeping the parent as a parameterized field rather than a +/// sibling node means the reflexive lowering has exactly one shape. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub(crate) struct ReflexivePaymentIr { - pub(crate) cost: AbilityCost, +pub(crate) struct ReflexiveParentIr { + /// How the parent instruction is printed and offered. + pub(crate) parent: ReflexiveParent, + /// The reflexive body — what `"When you do, …"` introduces. pub(crate) effect_chain: EffectChainIr, - pub(crate) payment_chain: Option, + /// CR 700.2b: modal metadata when the reflexive body is a mode choice. pub(crate) modal: Option, } +/// CR 118.12: the two printed shapes a reflexive parent can take. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) enum ReflexiveParent { + /// `"you may . When you do, …"` — a resolution-time offer the + /// controller may decline (Caesar, Legion's Emperor). `payment_chain` + /// carries the printed cost as an effect chain when one parsed; otherwise + /// lowering synthesizes an `Effect::PayCost` from `cost`. + MayPay { + cost: AbilityCost, + payment_chain: Option, + }, + /// `". When you do, …"` — the instruction is not an offer, it + /// simply happens (Cemetery Desecrator, Dialogue Tree). The trigger parser + /// already lowered the printed instruction into this chain, so lowering + /// reuses it instead of re-parsing the same words a second time. + Mandatory { instruction: EffectChainIr }, +} + /// CR 700.2: Typed inline-modal trigger body. /// /// The root marker is an ordinary effect chain so trigger lowering applies the diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index b49eb0513d..5261f47450 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -28,7 +28,9 @@ use super::oracle_ir::effect_chain::{ }; use super::oracle_ir::replacement::ReplacementIr; use super::oracle_ir::static_ir::StaticIr; -use super::oracle_ir::trigger::{ModalIr, ReflexivePaymentIr, TriggerBody, TriggerIr}; +use super::oracle_ir::trigger::{ + ModalIr, ReflexiveParent, ReflexiveParentIr, TriggerBody, TriggerIr, +}; use super::oracle_nom::bridge::nom_on_lower; use super::oracle_nom::condition as nom_condition; use super::oracle_nom::primitives::{self as nom_primitives, scan_preceded}; @@ -41,7 +43,7 @@ use super::oracle_trigger::parse_trigger_lines; use super::oracle_trigger::parse_trigger_lines_at_index_ir; use super::oracle_util::{parse_mana_symbols, strip_reminder_text, TextPair}; use crate::parser::oracle_ir::ast::{ - parsed_clause, ModalHeaderAst, ModalOptionality, ModeAst, OracleBlockAst, + parsed_clause, ModalHeaderAst, ModalOptionality, ModeAst, OracleBlockAst, ReflexiveModalParent, }; #[cfg(test)] use crate::types::ability::AbilityCondition; @@ -143,16 +145,13 @@ pub(crate) fn parse_oracle_block(lines: &[&str], start: usize) -> Option<(Oracle // out the cost so the lowering builds an `Effect::Sacrifice` whose // `WhenYouDo` sub carries the modal, instead of firing the modes // unconditionally on the trigger. - let (trigger_line, optional_cost) = match split_reflexive_optional_cost(&trigger_line) { - Some((trigger, cost)) => (trigger, Some(cost)), - None => (trigger_line, None), - }; + let (trigger_line, reflexive_parent) = classify_reflexive_modal_parent(trigger_line); return Some(( OracleBlockAst::TriggeredModal { trigger_line, header, modes, - optional_cost, + reflexive_parent, }, next, )); @@ -975,6 +974,54 @@ fn split_triggered_modal_header(line: &str) -> Option<(String, String)> { None } +/// CR 118.12 + CR 603.12: Classify how a triggered modal's mode list is +/// introduced, and reduce `trigger_line` to what the trigger parser should see. +/// +/// This is the single authority for that question. The reflexive CONNECTOR +/// decides whether a reflexive exists at all; the `"you may "` marker only says +/// how the parent instruction is offered (CR 118.12 prints both forms). Keying +/// the whole decision on the marker — as this dispatch did — silently answered +/// "no reflexive here" for every mandatory parent, and the mode list then +/// attached straight to the trigger with the printed instruction discarded. +/// +/// * `MayPay` — `trigger_line` is reduced to the bare trigger condition and the +/// cost text travels separately, because the cost is not something the +/// trigger parser can lower on its own (it is a resolution-time payment). +/// * `Mandatory` — `trigger_line` is returned WHOLE, instruction and connector +/// included. The trigger parser already lowers exactly this shape correctly +/// for every non-modal reflexive; lowering then hangs the modal off that +/// chain rather than replacing it. +/// * `None` — no connector: a plain triggered modal (Pip-Boy 3000). +fn classify_reflexive_modal_parent(trigger_line: String) -> (String, Option) { + if let Some((trigger, cost)) = split_reflexive_optional_cost(&trigger_line) { + return (trigger, Some(ReflexiveModalParent::MayPay(cost))); + } + if ends_in_reflexive_connector(&trigger_line) { + return (trigger_line, Some(ReflexiveModalParent::Mandatory)); + } + (trigger_line, None) +} + +/// CR 603.12: whether everything after `trigger_line`'s final sentence break is +/// the bare reflexive connector. +/// +/// `split_triggered_modal_header` has already taken the mode list away, so the +/// connector is the entire tail — there is no reflexive body left here to +/// consume. Scanning to the LAST break rather than the first is what keeps a +/// multi-sentence instruction ("Scry 1. When you do") from being read as a +/// connector that is not there. +fn ends_in_reflexive_connector(trigger_line: &str) -> bool { + let lower = trigger_line.to_lowercase(); + let mut tail = lower.as_str(); + while let Ok((after, _)) = + terminated(take_until::<_, _, OracleError<'_>>(". "), tag(". ")).parse(tail) + { + tail = after; + } + let tail = tail.trim().trim_end_matches(',').trim(); + nom_condition::match_when_you_do(tail).is_ok_and(|(rest, ())| rest.is_empty()) +} + /// CR 603.12 + CR 700.2b: Recognize a reflexive optional-cost trigger header of /// the shape `", you may . When you do"` (Caesar, Legion's /// Emperor) and split it into the bare trigger condition (`"Whenever you @@ -1113,7 +1160,7 @@ pub(crate) fn lower_oracle_block_ir( trigger_line, header, modes, - optional_cost, + reflexive_parent, } => { let mut trigger_ctx = ctx.clone(); trigger_ctx.host_self_reference = host_self_reference; @@ -1146,10 +1193,28 @@ pub(crate) fn lower_oracle_block_ir( modes: parse_modal_mode_irs(&modes, AbilityKind::Spell, &mut mode_ctx), }; ctx.diagnostics.extend(mode_ctx.diagnostics); - trigger.body = Some(match &optional_cost { - Some(cost_text) => { - TriggerBody::ReflexivePayment(Box::new(ReflexivePaymentIr { - cost: parse_oracle_cost(cost_text), + // CR 603.12: the printed instruction the mode list rides on is + // whatever `classify_reflexive_modal_parent` found. Compute the + // body before assigning it — the mandatory arm reads the chain + // the trigger parser already produced. + let body = match &reflexive_parent { + Some(ReflexiveModalParent::MayPay(cost_text)) => { + TriggerBody::Reflexive(Box::new(ReflexiveParentIr { + parent: ReflexiveParent::MayPay { + cost: parse_oracle_cost(cost_text), + payment_chain: Some( + parse_ability_ir_with_context( + cost_text, + AbilityKind::Spell, + &mut ParseContext { + actor: ctx.actor.clone(), + in_trigger: true, + ..Default::default() + }, + ) + .body, + ), + }, effect_chain: EffectChainIr::single_clause( cost_text, AbilityKind::Spell, @@ -1162,23 +1227,34 @@ pub(crate) fn lower_oracle_block_ir( None, true, ), - payment_chain: Some( - parse_ability_ir_with_context( - cost_text, - AbilityKind::Spell, - &mut ParseContext { - actor: ctx.actor.clone(), - in_trigger: true, - ..Default::default() - }, - ) - .body, - ), modal: Some(payload.clone()), })) } + // CR 118.12 + CR 608.2c: a mandatory parent. The trigger + // parser already lowered the printed instruction — the same + // path that handles every non-modal reflexive — so take + // that chain as the parent and hang the mode list off it as + // the CR 603.12 reflexive body. Replacing it (as this arm + // did) dropped the instruction from the card entirely. + Some(ReflexiveModalParent::Mandatory) => match trigger.body.take() { + Some(TriggerBody::EffectChain(instruction)) => { + TriggerBody::Reflexive(Box::new(ReflexiveParentIr { + parent: ReflexiveParent::Mandatory { instruction }, + effect_chain: payload.marker.clone(), + modal: Some(payload.clone()), + })) + } + // The connector is there but the instruction did not + // lower to a plain chain (it is itself a vote, pile or + // reflexive payment block). Those shapes carry their own + // root transforms and nesting them here would misreport + // what the card does, so keep the pre-existing plain + // modal rather than invent a parent. + _ => TriggerBody::Modal(Box::new(payload.clone())), + }, None => TriggerBody::Modal(Box::new(payload.clone())), - }); + }; + trigger.body = Some(body); if matches!(header.optionality, ModalOptionality::MayDecline) { trigger.modifiers.optional = true; } @@ -1391,7 +1467,7 @@ pub(crate) fn lower_oracle_block( trigger_line, header, modes, - optional_cost, + reflexive_parent, } => { let mut triggers = parse_trigger_lines(&trigger_line, card_name); // CR 608.2k + CR 301.5a: Derive the trigger subject from the parsed @@ -1433,7 +1509,11 @@ pub(crate) fn lower_oracle_block( modal_ability.optional = true; } - let execute = match optional_cost { + // CR 603.12: `None` means the modal attaches directly; either + // reflexive arm makes it the `WhenYouDo` body of the printed parent. + // `Mandatory` needs the per-trigger execute the trigger parser + // produced, so it is resolved inside the loop below. + let execute = match &reflexive_parent { // CR 603.12 + CR 700.2b: The modal is gated behind a reflexive // optional cost. Build `Effect::Sacrifice { optional }` whose // `WhenYouDo` sub_ability carries the modal, so the modes are @@ -1441,10 +1521,10 @@ pub(crate) fn lower_oracle_block( // (Caesar, Legion's Emperor). The decline path is handled by // `should_resolve_subability_on_optional_decline` (WhenYouDo → // false), so declining the sacrifice resolves no modes. - Some(cost_text) => { + Some(ReflexiveModalParent::MayPay(cost_text)) => { modal_ability.condition = Some(AbilityCondition::WhenYouDo); let mut cost_ability = crate::parser::oracle_effect::parse_effect_chain( - &cost_text, + cost_text, AbilityKind::Spell, ); // CR 118.12 + CR 701.21: "you may sacrifice" makes the @@ -1454,12 +1534,25 @@ pub(crate) fn lower_oracle_block( cost_ability.sub_ability = Some(Box::new(modal_ability)); Box::new(cost_ability) } + // CR 118.12 + CR 608.2c: a mandatory parent stays in the trigger + // line, so the parent is the trigger's own execute and the modal + // becomes its reflexive body. + Some(ReflexiveModalParent::Mandatory) => { + modal_ability.condition = Some(AbilityCondition::WhenYouDo); + Box::new(modal_ability) + } // Plain triggered modal (Pip-Boy): the modal attaches directly. None => Box::new(modal_ability), }; for trigger in &mut triggers { - trigger.execute = Some(execute.clone()); + match (&reflexive_parent, trigger.execute.take()) { + (Some(ReflexiveModalParent::Mandatory), Some(mut instruction)) => { + instruction.sub_ability = Some(execute.clone()); + trigger.execute = Some(instruction); + } + _ => trigger.execute = Some(execute.clone()), + } if matches!(header.optionality, ModalOptionality::MayDecline) { trigger.optional = true; } @@ -4212,6 +4305,90 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ ); } + /// CR 118.12: the classifier answers on the reflexive CONNECTOR, so the + /// `"you may "` marker only chooses between the two parent shapes. + /// + /// Table-driven on purpose: the marker and the connector are independent + /// axes, and the shipped defect was exactly one cell of this table — a + /// connector with no marker — being read as "no reflexive at all". + #[test] + fn classify_reflexive_modal_parent_keys_on_the_connector_not_the_marker() { + let rows: &[(&str, Option, &str)] = &[ + // Marker + connector: Caesar. The instruction leaves `trigger_line` + // because a resolution payment is not something the trigger parser + // can lower on its own. + ( + "Whenever you attack, you may sacrifice another creature. When you do", + Some(ReflexiveModalParent::MayPay( + "Sacrifice another creature".to_string(), + )), + "Whenever you attack", + ), + // Connector, NO marker: Cemetery Desecrator. The instruction stays + // in `trigger_line` for the ordinary trigger parser to lower. + ( + "When this creature enters or dies, exile another card from a graveyard. When you do", + Some(ReflexiveModalParent::Mandatory), + "When this creature enters or dies, exile another card from a graveyard. When you do", + ), + // Neither: Pip-Boy 3000 stays a plain triggered modal. + ( + "Whenever equipped creature attacks", + None, + "Whenever equipped creature attacks", + ), + // Marker, NO connector: a plain optional effect, not a reflexive. + ( + "Whenever you attack, you may draw a card", + None, + "Whenever you attack, you may draw a card", + ), + ]; + for (line, expected_parent, expected_trigger) in rows { + let (trigger, parent) = classify_reflexive_modal_parent(line.to_string()); + assert_eq!(&parent, expected_parent, "parent for {line:?}"); + assert_eq!(&trigger.as_str(), expected_trigger, "trigger for {line:?}"); + } + } + + /// CR 118.12 + CR 608.2c: a mandatory instruction printed before the mode + /// list is kept and the modal becomes its CR 603.12 reflexive body — the + /// same shape Caesar's optional parent produces, minus the decline. + /// + /// Guards the class, not the card: `Mandatory` carries no card-specific + /// text, so any printed instruction the trigger parser can lower reaches + /// this shape. + #[test] + fn a_mandatory_parent_keeps_its_instruction_under_the_mode_list() { + let parsed = parse_oracle_text( + "When this creature enters, exile another card from a graveyard. When you do, choose one —\n• Draw a card.\n• You gain 2 life.", + "Class Probe", + &[], + &["Creature".to_string()], + &[], + ); + let execute = parsed + .triggers + .first() + .and_then(|t| t.execute.as_ref()) + .expect("the enters trigger must carry an execute"); + assert!( + matches!(*execute.effect, Effect::ChangeZone { .. }), + "the printed instruction must survive as the parent effect, got {:?}", + execute.effect + ); + assert!( + !execute.optional, + "a mandatory instruction is not an offer and must not be marked optional" + ); + let sub = execute + .sub_ability + .as_ref() + .expect("the mode list must hang off the instruction as its reflexive body"); + assert_eq!(sub.condition, Some(AbilityCondition::WhenYouDo)); + assert_eq!(sub.mode_abilities.len(), 2, "both modes must survive"); + } + #[test] fn caesar_lowers_to_reflexive_gated_modal() { // CR 603.12 + CR 700.2b: Caesar's attack trigger must lower to an @@ -4287,7 +4464,7 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ item.source.fragment(), Some("Whenever you attack, you may sacrifice another creature. When you do, choose two —") ); - let Some(TriggerBody::ReflexivePayment(reflexive)) = trigger.body.as_ref() else { + let Some(TriggerBody::Reflexive(reflexive)) = trigger.body.as_ref() else { panic!("Caesar must retain its native reflexive-payment trigger body"); }; let modal = reflexive diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 11ccbafb0f..6e127049b5 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -18,7 +18,8 @@ use super::oracle_ir::context::{ParseContext, TriggerConditionScope}; use super::oracle_ir::doc::PrintedTriggerIndex; use super::oracle_ir::effect_chain::{DieResultBranchIr, EffectChainIr}; use super::oracle_ir::trigger::{ - FirstTimeLimit, ReflexivePaymentIr, TriggerBody, TriggerIr, TriggerModifiers, TriggerNodeIr, + FirstTimeLimit, ReflexiveParent, ReflexiveParentIr, TriggerBody, TriggerIr, TriggerModifiers, + TriggerNodeIr, }; use super::oracle_modal::try_parse_inline_modal_ir; use super::oracle_nom::condition::parse_elided_subject_state_condition; @@ -1506,14 +1507,14 @@ pub(crate) fn parse_trigger_line_with_index_ir( optional = false; let effect_chain = parse_effect_chain_ir(&reflexive_effect_text, AbilityKind::Spell, &mut effect_ctx); - Some(TriggerBody::ReflexivePayment(Box::new( - ReflexivePaymentIr { + Some(TriggerBody::Reflexive(Box::new(ReflexiveParentIr { + parent: ReflexiveParent::MayPay { cost, - effect_chain, payment_chain: None, - modal: None, }, - ))) + effect_chain, + modal: None, + }))) } else if is_unsupported_disjunctive_reflexive_optional_payment(&effect_for_parse) { Some(TriggerBody::EffectChain(EffectChainIr::single_clause( &effect_for_parse, @@ -1764,7 +1765,7 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { modifiers, &ir.die_results, ))), - Some(TriggerBody::ReflexivePayment(reflexive)) => { + Some(TriggerBody::Reflexive(reflexive)) => { let mut reflexive_ability = lower_trigger_effect_chain(&reflexive.effect_chain, modifiers, &ir.die_results); reflexive_ability.condition = Some(AbilityCondition::WhenYouDo); @@ -1780,20 +1781,39 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { ); } - let mut pay_ability = match &reflexive.payment_chain { - Some(chain) => lower_trigger_effect_chain(chain, modifiers, &[]), - None => AbilityDefinition::new( - AbilityKind::Spell, - Effect::PayCost { - cost: reflexive.cost.clone(), - scale: None, - payer: TargetFilter::Controller, - }, - ), + // CR 118.12: the parent is a cost the controller may decline, or a + // printed instruction that simply happens. `optional` is the ONLY + // difference between the two arms — CR 603.12 gates the reflexive + // on the event in both cases, so both build the same parent → + // `WhenYouDo` sub shape. + let mut parent_ability = match &reflexive.parent { + ReflexiveParent::MayPay { + cost, + payment_chain, + } => { + let mut pay_ability = match payment_chain { + Some(chain) => lower_trigger_effect_chain(chain, modifiers, &[]), + None => AbilityDefinition::new( + AbilityKind::Spell, + Effect::PayCost { + cost: cost.clone(), + scale: None, + payer: TargetFilter::Controller, + }, + ), + }; + pay_ability.optional = true; + pay_ability + } + // CR 118.12 + CR 608.2c: a mandatory instruction is the next + // printed instruction, not an offer — it carries no `optional` + // flag and no decline prompt. + ReflexiveParent::Mandatory { instruction } => { + lower_trigger_effect_chain(instruction, modifiers, &[]) + } }; - pay_ability.optional = true; - pay_ability.sub_ability = Some(Box::new(reflexive_ability)); - Some(Box::new(pay_ability)) + parent_ability.sub_ability = Some(Box::new(reflexive_ability)); + Some(Box::new(parent_ability)) } Some(TriggerBody::Modal(modal)) => Some(Box::new( lower_trigger_effect_chain(&modal.marker, modifiers, &[]).with_modal( diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index a688071dd7..6115b35bb1 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1242,6 +1242,7 @@ mod love_on_the_battlefield_combat_counters; mod loyalty_ability_activated_trigger; mod loyalty_replacement_order_resume; mod mad_mage_lost_level_scry_runtime; +mod mandatory_reflexive_modal_parent; mod mass_unsuspect_701_60a; mod memory_vessel_std_s25; mod menace_requires_two_blockers; diff --git a/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs b/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs new file mode 100644 index 0000000000..af15583f95 --- /dev/null +++ b/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs @@ -0,0 +1,173 @@ +//! CR 118.12 + CR 603.12: a MANDATORY instruction printed before a reflexive +//! "When you do" whose body is a mode list. +//! +//! Cemetery Desecrator — "When this creature enters or dies, exile another card +//! from a graveyard. When you do, choose one — • Remove X counters from target +//! permanent, where X is the mana value of the exiled card. • Target creature an +//! opponent controls gets -X/-X until end of turn, where X is the mana value of +//! the exiled card." +//! +//! Reported from a real game with every graveyard empty: the enters trigger +//! asked for a mode although there was no card anywhere to exile. Reading the +//! parse showed the cause is upstream of that symptom — the exile instruction +//! was not merely ungated, it was absent. The triggered-modal dispatch keyed the +//! whole reflexive decision on the printed `"you may "` marker, so a mandatory +//! parent classified as "no reflexive here" and the mode list replaced the +//! instruction outright. The card never exiled anything, in any game state. +//! +//! Oracle text verified against `client/public/card-data.json`. +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 118.12: the parent is printed two ways — "[Do something]. If [a player] +//! [does] …" and "[A player] may [do something]. If [that player] [does] …". +//! The marker says how the instruction is offered, not whether one exists. +//! - CR 603.12: a reflexive triggered ability triggers "based on whether the +//! trigger event or events occurred earlier during the resolution of the +//! spell or ability that created them". +//! - CR 700.2b: a modal triggered ability chooses its mode(s) as it is put on +//! the stack. +//! +//! What this file does NOT prove: it does not measure the CR 603.12 gate. With +//! every graveyard empty the instruction now runs and exiles nothing, but the +//! reflexive is still created and still asks for a mode. Suppressing it needs +//! the engine to record that a mandatory instruction did nothing — issue #7511's +//! remaining half, deliberately out of scope here. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Copied verbatim from `client/public/card-data.json`, newlines included — +/// the mode list is line-separated there, and joining it onto one line is not +/// the printed text the parser is given at runtime. +const DESECRATOR: &str = "When this creature enters or dies, exile another card from a graveyard. When you do, choose one —\n• Remove X counters from target permanent, where X is the mana value of the exiled card.\n• Target creature an opponent controls gets -X/-X until end of turn, where X is the mana value of the exiled card."; + +struct Resolution { + prompts: Vec, + exiled: usize, + graveyard: usize, +} + +/// Cast Cemetery Desecrator and record what its enters trigger did. +/// +/// `graveyard_fodder` is the whole variable: one card sitting in the opponent's +/// graveyard is the only thing "exile another card from a graveyard" can reach. +fn resolve_enters(graveyard_fodder: bool) -> Resolution { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // The opponent's creature is the legal target for the second mode. Without + // it the mode could be skipped for want of a target, which would hide the + // behavior rather than measure it. + scenario.add_creature_from_oracle(P1, "Warded Bear", 2, 2, "Ward {2}"); + if graveyard_fodder { + scenario.with_graveyard(P1, &["Fodder Card"]); + } + let desecrator = scenario + .add_spell_to_hand_from_oracle(P0, "Cemetery Desecrator", false, DESECRATOR) + .as_creature() + .id(); + for _ in 0..10 { + scenario.add_card_to_library_top(P0, "Plains"); + } + + let mut runner = scenario.build(); + runner.cast(desecrator).commit(); + + let mut prompts = Vec::new(); + for _ in 0..40 { + let (label, action) = match &runner.state().waiting_for { + // Priority with an empty stack is the resting state: the spell, its + // enters trigger and any reflexive it created have all finished. + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + WaitingFor::Priority { .. } => (None, GameAction::PassPriority), + WaitingFor::AbilityModeChoice { .. } => ( + Some("AbilityModeChoice".to_string()), + GameAction::SelectModes { indices: vec![1] }, + ), + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + prompts.push("TargetSelection".to_string()); + break; + } + other => { + prompts.push(format!("PROMPT: {}", other.variant_name())); + break; + } + }; + if let Some(label) = label { + prompts.push(label); + } + if runner.act(action).is_err() { + break; + } + } + + // Count only the fodder card: the Desecrator itself is on the battlefield + // and the library cards never moved, so a zone census over the named card + // cannot be satisfied by anything else the resolution touched. + let count_in = |zone: Zone| { + runner + .state() + .objects + .values() + .filter(|o| o.name == "Fodder Card" && o.zone == zone) + .count() + }; + Resolution { + prompts, + exiled: count_in(Zone::Exile), + graveyard: count_in(Zone::Graveyard), + } +} + +/// CR 118.12: the printed instruction before the reflexive connector is a real +/// instruction and must be performed. +/// +/// Counter-probe: with the `Mandatory` arm removed from +/// `classify_reflexive_modal_parent` this line reads `left: (0, 1)` against +/// `right: (1, 0)` — the card exiles nothing and the fodder stays in the +/// graveyard, which is exactly the shipped behavior this fixes. +#[test] +fn the_mandatory_instruction_before_a_mode_list_is_performed() { + let resolved = resolve_enters(true); + assert_eq!( + (resolved.exiled, resolved.graveyard), + (1, 0), + "\"exile another card from a graveyard\" must move the one reachable card \ + out of the graveyard and into exile — prompts seen: {:?}", + resolved.prompts + ); +} + +/// The mode list must still be offered once the instruction has run — the fix +/// re-parents the modal, it does not remove it. +/// +/// This is the positive counter-direction: a fix that suppressed the modal +/// altogether would satisfy the test above and break the card a second way. +#[test] +fn the_mode_list_still_resolves_after_the_instruction() { + let resolved = resolve_enters(true); + assert!( + resolved.prompts.iter().any(|p| p == "AbilityModeChoice"), + "the reflexive mode choice must still be offered — {:?}", + resolved.prompts + ); +} + +/// With nothing to exile, the instruction runs and moves no card. +/// +/// This row does NOT assert that the resolution asks nothing. It still asks for +/// a mode: CR 603.12 says the reflexive should never have been created, but the +/// engine has no record that a mandatory instruction did nothing. That gap is +/// issue #7511's remaining half and is not addressed here. +#[test] +fn an_impossible_exile_moves_no_card() { + let resolved = resolve_enters(false); + assert_eq!( + (resolved.exiled, resolved.graveyard), + (0, 0), + "no graveyard held a card, so nothing may be exiled — prompts seen: {:?}", + resolved.prompts + ); +} From 12fcd6b7733cebb890837418e0e2ba67e5929943 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:36:41 +0200 Subject: [PATCH 2/7] test(parser): drive the reflexive modal to resolution and cover the second surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings, both accepted: - The target-selection arms recorded the prompt and stopped, so the chosen mode never resolved and the row proved only that a question was asked. Every slot is now answered with its first legal target, ward is declined (CR 702.21a), and the driver asserts it reached a settled empty stack. - `an_impossible_exile_moves_no_card` censused an object that does not exist in that fixture, so `(0, 0)` held even if nothing resolved. It now carries a positive reach-guard on the mode choice. That guard passes with or without the fix by design — it proves the row was reached, not that the fix works. Also covers the class's second printed surface: the connector may be followed by an intervening condition before the mode list ("When you do, if you control five or more …, choose one —"). The Cobra King takes that shape and lost its Cobra Coil token exactly as Cemetery Desecrator lost its exile. Both cards are what the CI parse-diff reports as changed. Not asserted, and unchanged by this PR: The Cobra King's "if you control five or more Snakes and/or Serpents" gate lands in the modal header and is represented neither before nor after. Counter-probe, re-measured against the reworked driver: without the `Mandatory` arm, `the_mandatory_instruction_before_a_mode_list_is_performed` reads `left: (0, 1)` against `right: (1, 0)`, and both parser class rows fail. Co-Authored-By: Claude Opus 5 --- crates/engine/src/parser/oracle_modal.rs | 36 ++++++++++++++ .../mandatory_reflexive_modal_parent.rs | 49 ++++++++++++++++--- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 5261f47450..390b03fae9 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -4389,6 +4389,42 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ assert_eq!(sub.mode_abilities.len(), 2, "both modes must survive"); } + /// CR 603.12: the connector may be followed by an intervening condition + /// before the mode list ("When you do, if you control five or more …, + /// choose one —"). The modal header split then leaves the trigger line + /// ending on the bare connector, which is the second surface this class + /// takes — The Cobra King, whose Cobra Coil token was dropped the same way + /// Cemetery Desecrator's exile was. + /// + /// Does NOT assert the "five or more" gate: that condition lands in the + /// modal header and is unrepresented both before and after this change. + #[test] + fn a_mandatory_parent_survives_a_condition_between_connector_and_modes() { + let parsed = parse_oracle_text( + "At the beginning of each player's upkeep, create a 1/1 blue Serpent creature token named Cobra Coil. When you do, if you control five or more Snakes and/or Serpents, choose one —\n• Strike first — Target Snake or Serpent you control fights target creature an opponent controls.\n• Strike hard — Put a +1/+1 counter on each Snake and Serpent you control.", + "The Cobra King", + &[], + &["Legendary".to_string(), "Creature".to_string()], + &["Snake".to_string()], + ); + let execute = parsed + .triggers + .first() + .and_then(|t| t.execute.as_ref()) + .expect("the upkeep trigger must carry an execute"); + assert!( + matches!(*execute.effect, Effect::Token { .. }), + "the printed token instruction must survive as the parent effect, got {:?}", + execute.effect + ); + let sub = execute + .sub_ability + .as_ref() + .expect("the mode list must hang off the instruction as its reflexive body"); + assert_eq!(sub.condition, Some(AbilityCondition::WhenYouDo)); + assert_eq!(sub.mode_abilities.len(), 2, "both modes must survive"); + } + #[test] fn caesar_lowers_to_reflexive_gated_modal() { // CR 603.12 + CR 700.2b: Caesar's attack trigger must lower to an diff --git a/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs b/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs index af15583f95..4812519909 100644 --- a/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs +++ b/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs @@ -76,20 +76,43 @@ fn resolve_enters(graveyard_fodder: bool) -> Resolution { runner.cast(desecrator).commit(); let mut prompts = Vec::new(); + let mut settled = false; for _ in 0..40 { - let (label, action) = match &runner.state().waiting_for { + let (label, action) = match runner.state().waiting_for.clone() { // Priority with an empty stack is the resting state: the spell, its - // enters trigger and any reflexive it created have all finished. - WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + // enters trigger and the reflexive it created have all finished. + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => { + settled = true; + break; + } WaitingFor::Priority { .. } => (None, GameAction::PassPriority), WaitingFor::AbilityModeChoice { .. } => ( Some("AbilityModeChoice".to_string()), GameAction::SelectModes { indices: vec![1] }, ), - WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { - prompts.push("TargetSelection".to_string()); - break; + // Answer every slot with its first legal target so the chosen mode + // actually resolves. Recording the prompt and stopping here would + // leave the reflexive half-resolved and prove only that a question + // was asked. + WaitingFor::TriggerTargetSelection { target_slots, .. } + | WaitingFor::TargetSelection { target_slots, .. } => { + let targets: Vec<_> = target_slots + .iter() + .filter_map(|slot| slot.legal_targets.first().cloned()) + .collect(); + ( + Some("TargetSelection".to_string()), + GameAction::SelectTargets { targets }, + ) } + // CR 702.21a: the chosen mode targets the opponent's warded + // creature. Declining the ward cost counters that mode and lets the + // resolution finish, which is what makes the settled state + // reachable without turning this file into a ward test. + WaitingFor::UnlessPayment { .. } => ( + Some("UnlessPayment".to_string()), + GameAction::PayUnlessCost { pay: false }, + ), other => { prompts.push(format!("PROMPT: {}", other.variant_name())); break; @@ -102,6 +125,10 @@ fn resolve_enters(graveyard_fodder: bool) -> Resolution { break; } } + assert!( + settled, + "the resolution must reach an empty stack — prompts seen: {prompts:?}" + ); // Count only the fodder card: the Desecrator itself is on the battlefield // and the library cards never moved, so a zone census over the named card @@ -164,6 +191,16 @@ fn the_mode_list_still_resolves_after_the_instruction() { #[test] fn an_impossible_exile_moves_no_card() { let resolved = resolve_enters(false); + // Reach-guard: no object named "Fodder Card" exists in this game, so the + // census below would read (0, 0) even if the card failed to parse or the + // trigger never fired. The mode choice proves the enters trigger resolved + // its instruction and created the reflexive. + assert!( + resolved.prompts.iter().any(|p| p == "AbilityModeChoice"), + "the enters trigger must have run its instruction and created the \ + reflexive mode choice — {:?}", + resolved.prompts + ); assert_eq!( (resolved.exiled, resolved.graveyard), (0, 0), From ea6dbd22bdd94d4990b9352e3b5dc0b9feb57f80 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 03:09:14 -0700 Subject: [PATCH 3/7] fix(parser): correct reflexive modal rule annotations Correct the mandatory and optional WhenYouDo documentation to cite CR 603.12 rather than the separate If-you-do cost rule, and make the Mandatory TriggerBody fallback exhaustive without changing its behavior. Co-authored-by: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> --- crates/engine/src/parser/oracle_ir/ast.rs | 17 +++---- crates/engine/src/parser/oracle_ir/trigger.rs | 18 +++---- crates/engine/src/parser/oracle_modal.rs | 51 ++++++++++--------- crates/engine/src/parser/oracle_trigger.rs | 10 ++-- .../mandatory_reflexive_modal_parent.rs | 7 +-- 5 files changed, 49 insertions(+), 54 deletions(-) diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 29e5f56416..aea88a780c 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1992,20 +1992,17 @@ fn normalize_play_from_exile_duration(duration: Duration) -> Duration { // --- Modal types (moved from oracle_modal.rs) --- -/// CR 118.12 + CR 603.12: The printed instruction a triggered modal's reflexive +/// CR 603.12: The printed instruction a triggered modal's reflexive /// connector rides on — `", . When you do, choose …"`. /// -/// CR 118.12 prints the instruction two ways, and the connector reads the same -/// in both: `"[A player] may [do something]. If [that player] [does] …"` and -/// the bare `"[Do something]. If [a player] [does] …"`. Classifying on the -/// `"you may "` marker alone therefore answers the wrong question — the marker -/// says how the instruction is OFFERED, not whether a reflexive exists. The -/// connector is what decides that, so this type keeps the two apart. +/// The `"When you do"` connector creates the reflexive triggered ability. The +/// `"you may "` marker only makes its parent instruction optional, so it cannot +/// decide whether a reflexive exists. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) enum ReflexiveModalParent { - /// `"…, you may . When you do, choose …"` — a declinable - /// resolution-time payment (Caesar, Legion's Emperor). Carries the printed - /// cost text with the `"you may "` marker and the connector stripped; + /// `"…, you may . When you do, choose …"` — a declinable + /// resolution-time instruction (Caesar, Legion's Emperor). Carries the + /// printed instruction text with the `"you may "` marker and connector stripped; /// `trigger_line` is reduced to the bare trigger condition alongside it. MayPay(String), /// `"…, . When you do, choose …"` — a mandatory instruction diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index 40911c08aa..fd0f0e2f90 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -116,7 +116,7 @@ impl TriggerIr { pub(crate) enum TriggerBody { /// Normal effect chain — lowering calls `lower_effect_chain_ir`. EffectChain(EffectChainIr), - /// CR 118.12 + CR 603.12: A printed parent instruction and the reflexive + /// CR 603.12: A printed parent instruction and the reflexive /// effect that follows when its event occurred. Reflexive(Box), /// CR 700.2: An inline modal's marker clause and its already-lowered mode @@ -134,12 +134,10 @@ pub(crate) enum TriggerBody { /// instruction it rides on. /// /// `parent` is the axis that used to be assumed rather than represented: this -/// node only existed for the `"you may . When you do"` surface, so a -/// MANDATORY parent had nowhere to live. CR 118.12 prints both — "[Do -/// something]. If [a player] [does] …" and "[A player] may [do something]. If -/// [that player] [does] …" — and CR 603.12 gates the reflexive on the event -/// either way. Keeping the parent as a parameterized field rather than a -/// sibling node means the reflexive lowering has exactly one shape. +/// node only existed for the `"you may . When you do"` surface, so +/// a mandatory parent had nowhere to live. Keeping the parent as a parameterized +/// field rather than a sibling node means the reflexive lowering has exactly one +/// shape. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct ReflexiveParentIr { /// How the parent instruction is printed and offered. @@ -150,12 +148,12 @@ pub(crate) struct ReflexiveParentIr { pub(crate) modal: Option, } -/// CR 118.12: the two printed shapes a reflexive parent can take. +/// CR 603.12: the two printed forms a reflexive parent can take. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) enum ReflexiveParent { - /// `"you may . When you do, …"` — a resolution-time offer the + /// `"you may . When you do, …"` — a resolution-time offer the /// controller may decline (Caesar, Legion's Emperor). `payment_chain` - /// carries the printed cost as an effect chain when one parsed; otherwise + /// carries the printed instruction as an effect chain when one parsed; otherwise /// lowering synthesizes an `Effect::PayCost` from `cost`. MayPay { cost: AbilityCost, diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 390b03fae9..165fd795c1 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -974,19 +974,19 @@ fn split_triggered_modal_header(line: &str) -> Option<(String, String)> { None } -/// CR 118.12 + CR 603.12: Classify how a triggered modal's mode list is +/// CR 603.12: Classify how a triggered modal's mode list is /// introduced, and reduce `trigger_line` to what the trigger parser should see. /// /// This is the single authority for that question. The reflexive CONNECTOR /// decides whether a reflexive exists at all; the `"you may "` marker only says -/// how the parent instruction is offered (CR 118.12 prints both forms). Keying -/// the whole decision on the marker — as this dispatch did — silently answered +/// whether the parent instruction is optional. Keying the whole decision on the +/// marker — as this dispatch did — silently answered /// "no reflexive here" for every mandatory parent, and the mode list then /// attached straight to the trigger with the printed instruction discarded. /// /// * `MayPay` — `trigger_line` is reduced to the bare trigger condition and the -/// cost text travels separately, because the cost is not something the -/// trigger parser can lower on its own (it is a resolution-time payment). +/// instruction text travels separately, because that optional instruction is +/// lowered by the resolution-time path. /// * `Mandatory` — `trigger_line` is returned WHOLE, instruction and connector /// included. The trigger parser already lowers exactly this shape correctly /// for every non-modal reflexive; lowering then hangs the modal off that @@ -1022,17 +1022,17 @@ fn ends_in_reflexive_connector(trigger_line: &str) -> bool { nom_condition::match_when_you_do(tail).is_ok_and(|(rest, ())| rest.is_empty()) } -/// CR 603.12 + CR 700.2b: Recognize a reflexive optional-cost trigger header of -/// the shape `", you may . When you do"` (Caesar, Legion's +/// CR 603.12 + CR 700.2b: Recognize a reflexive optional-instruction trigger +/// header of the shape `", you may . When you do"` (Caesar, Legion's /// Emperor) and split it into the bare trigger condition (`"Whenever you -/// attack"`) and the cost effect text (`"Sacrifice another creature"`, with the +/// attack"`) and the instruction text (`"Sacrifice another creature"`, with the /// `"you may "` optional marker and the trailing `". When you do"` reflexive /// connector stripped). Returns `None` for a plain triggered modal (Pip-Boy /// 3000's `"Whenever equipped creature attacks ..."`), which has neither a -/// `"you may "` optional cost nor a `"when you do"` reflexive connector — that +/// `"you may "` optional instruction nor a `"when you do"` reflexive connector — that /// modal attaches directly to the trigger's execute. /// -/// The cost text is returned with an uppercased leading letter so it parses as +/// The instruction text is returned with an uppercased leading letter so it parses as /// an imperative effect clause (`parse_effect_chain` expects sentence case). fn split_reflexive_optional_cost(trigger_line: &str) -> Option<(String, String)> { // Combinator (run on lowercase, slice original by equal ASCII byte offset): @@ -1230,7 +1230,7 @@ pub(crate) fn lower_oracle_block_ir( modal: Some(payload.clone()), })) } - // CR 118.12 + CR 608.2c: a mandatory parent. The trigger + // CR 603.12 + CR 608.2c: a mandatory parent. The trigger // parser already lowered the printed instruction — the same // path that handles every non-modal reflexive — so take // that chain as the parent and hang the mode list off it as @@ -1245,12 +1245,17 @@ pub(crate) fn lower_oracle_block_ir( })) } // The connector is there but the instruction did not - // lower to a plain chain (it is itself a vote, pile or - // reflexive payment block). Those shapes carry their own + // lower to a plain chain. These shapes carry their own // root transforms and nesting them here would misreport // what the card does, so keep the pre-existing plain // modal rather than invent a parent. - _ => TriggerBody::Modal(Box::new(payload.clone())), + Some( + TriggerBody::Reflexive(_) + | TriggerBody::Modal(_) + | TriggerBody::Vote(_) + | TriggerBody::Pile(_), + ) + | None => TriggerBody::Modal(Box::new(payload.clone())), }, None => TriggerBody::Modal(Box::new(payload.clone())), }; @@ -1515,9 +1520,9 @@ pub(crate) fn lower_oracle_block( // produced, so it is resolved inside the loop below. let execute = match &reflexive_parent { // CR 603.12 + CR 700.2b: The modal is gated behind a reflexive - // optional cost. Build `Effect::Sacrifice { optional }` whose + // optional instruction. Build `Effect::Sacrifice { optional }` whose // `WhenYouDo` sub_ability carries the modal, so the modes are - // chosen and resolved only after the controller pays the cost + // chosen and resolved only after the controller performs that instruction // (Caesar, Legion's Emperor). The decline path is handled by // `should_resolve_subability_on_optional_decline` (WhenYouDo → // false), so declining the sacrifice resolves no modes. @@ -1527,14 +1532,14 @@ pub(crate) fn lower_oracle_block( cost_text, AbilityKind::Spell, ); - // CR 118.12 + CR 701.21: "you may sacrifice" makes the - // sacrifice cost optional during resolution; the controller - // is prompted before paying it. + // CR 603.12 + CR 701.21: "you may sacrifice" makes the + // sacrifice instruction optional during resolution; the + // controller chooses whether to perform it. cost_ability.optional = true; cost_ability.sub_ability = Some(Box::new(modal_ability)); Box::new(cost_ability) } - // CR 118.12 + CR 608.2c: a mandatory parent stays in the trigger + // CR 603.12 + CR 608.2c: a mandatory parent stays in the trigger // line, so the parent is the trigger's own execute and the modal // becomes its reflexive body. Some(ReflexiveModalParent::Mandatory) => { @@ -4305,7 +4310,7 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ ); } - /// CR 118.12: the classifier answers on the reflexive CONNECTOR, so the + /// CR 603.12: the classifier answers on the reflexive CONNECTOR, so the /// `"you may "` marker only chooses between the two parent shapes. /// /// Table-driven on purpose: the marker and the connector are independent @@ -4351,8 +4356,8 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ } } - /// CR 118.12 + CR 608.2c: a mandatory instruction printed before the mode - /// list is kept and the modal becomes its CR 603.12 reflexive body — the + /// CR 603.12 + CR 608.2c: a mandatory instruction printed before the mode + /// list is kept and the modal becomes its reflexive body — the /// same shape Caesar's optional parent produces, minus the decline. /// /// Guards the class, not the card: `Mandatory` carries no card-specific diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 6e127049b5..9b03bb4773 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -1781,11 +1781,9 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { ); } - // CR 118.12: the parent is a cost the controller may decline, or a - // printed instruction that simply happens. `optional` is the ONLY - // difference between the two arms — CR 603.12 gates the reflexive - // on the event in both cases, so both build the same parent → - // `WhenYouDo` sub shape. + // CR 603.12: the parent is either an optional instruction the + // controller may decline or a mandatory instruction. Both build the + // same parent → `WhenYouDo` sub shape. let mut parent_ability = match &reflexive.parent { ReflexiveParent::MayPay { cost, @@ -1805,7 +1803,7 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { pay_ability.optional = true; pay_ability } - // CR 118.12 + CR 608.2c: a mandatory instruction is the next + // CR 603.12 + CR 608.2c: a mandatory instruction is the next // printed instruction, not an offer — it carries no `optional` // flag and no decline prompt. ReflexiveParent::Mandatory { instruction } => { diff --git a/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs b/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs index 4812519909..1474757f1d 100644 --- a/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs +++ b/crates/engine/tests/integration/mandatory_reflexive_modal_parent.rs @@ -1,4 +1,4 @@ -//! CR 118.12 + CR 603.12: a MANDATORY instruction printed before a reflexive +//! CR 603.12: a mandatory instruction printed before a reflexive //! "When you do" whose body is a mode list. //! //! Cemetery Desecrator — "When this creature enters or dies, exile another card @@ -18,9 +18,6 @@ //! Oracle text verified against `client/public/card-data.json`. //! //! CR references (verified against docs/MagicCompRules.txt): -//! - CR 118.12: the parent is printed two ways — "[Do something]. If [a player] -//! [does] …" and "[A player] may [do something]. If [that player] [does] …". -//! The marker says how the instruction is offered, not whether one exists. //! - CR 603.12: a reflexive triggered ability triggers "based on whether the //! trigger event or events occurred earlier during the resolution of the //! spell or ability that created them". @@ -148,7 +145,7 @@ fn resolve_enters(graveyard_fodder: bool) -> Resolution { } } -/// CR 118.12: the printed instruction before the reflexive connector is a real +/// CR 603.12: the printed instruction before the reflexive connector is a real /// instruction and must be performed. /// /// Counter-probe: with the `Mandatory` arm removed from From ade87a17d2fff3a33cc4ff8826be49327ee58a90 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 04:09:59 -0700 Subject: [PATCH 4/7] fix(parser): route die tables through reflexive parents Attach result rows to the printed parent instruction for mandatory reflexive modals while retaining nested reflexive-roll ownership. Co-authored-by: cuinhellcat --- crates/engine/src/parser/oracle.rs | 8 +++- crates/engine/src/parser/oracle_ir/trigger.rs | 43 ++++++++++++++---- crates/engine/src/parser/oracle_modal.rs | 45 +++++++++++++++++++ crates/engine/src/parser/oracle_trigger.rs | 24 +++++++--- 4 files changed, 104 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index fc56b42ae1..c3d5ab9f00 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -4598,6 +4598,7 @@ pub(crate) fn parse_oracle_ir( // Must run before keyword extraction so "Spree" header + follow-on `+` lines // are consumed as a modal block, not swallowed as a keyword-only line. if let Some((block, next_i)) = parse_oracle_block(&lines, i) { + let mut next_i = next_i; match lower_oracle_block_ir(block, card_name, ctx.host_self_reference.clone(), &mut ctx) { OracleBlockIr::Activated(ability) => { @@ -4613,7 +4614,12 @@ pub(crate) fn parse_oracle_ir( } emitter.modal_at(item_line, choice); } - OracleBlockIr::Triggered(triggers) => { + OracleBlockIr::Triggered(mut triggers) => { + // CR 706.3b: a triggered modal consumes its bullet modes + // before this boundary, so table rows follow `next_i`, not + // the trigger header. Retain them on the trigger IR until + // lowering can attach them to the chain that owns the roll. + next_i = attach_trigger_die_result_branches(&mut triggers, &lines, next_i); for trigger in triggers { emitter.trigger_ir_at(item_line, TriggerNodeIr::Parsed(Box::new(trigger))); } diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index fd0f0e2f90..dcbe1da1a4 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -95,21 +95,46 @@ pub(crate) struct TriggerIr { impl TriggerIr { /// Whether the body ends in the typed die-roll node that owns a result table. pub(crate) fn has_terminal_roll_die(&self) -> bool { - let chain = match &self.body { - Some(TriggerBody::EffectChain(chain)) => chain, - Some(TriggerBody::Reflexive(reflexive)) => &reflexive.effect_chain, + match &self.body { + Some(TriggerBody::EffectChain(chain)) => effect_chain_has_terminal_roll_die(chain), + // CR 706.3b: the table belongs to the printed die-roll instruction, + // not the reflexive `WhenYouDo` body it creates. A modal reflexive + // body contains only the mode marker, so looking there drops rows + // for a parent such as "roll a d20. When you do, choose one". + Some(TriggerBody::Reflexive(reflexive)) => { + effect_chain_has_terminal_roll_die(&reflexive.effect_chain) + || match &reflexive.parent { + ReflexiveParent::MayPay { + payment_chain: Some(chain), + .. + } => effect_chain_has_terminal_roll_die(chain), + ReflexiveParent::MayPay { + payment_chain: None, + .. + } => false, + ReflexiveParent::Mandatory { instruction } => { + effect_chain_has_terminal_roll_die(instruction) + } + } + } Some(TriggerBody::Modal(_)) | Some(TriggerBody::Vote(_)) | Some(TriggerBody::Pile(_)) - | None => return false, - }; - let Some(clause) = chain.clauses.last() else { - return false; - }; - matches!(clause.parsed.effect, Effect::RollDie { .. }) + | None => false, + } } } +/// Whether this exact chain ends at the typed die roll that owns its following +/// result table. Parent and reflexive chains are distinct printed instructions, +/// so callers use this to preserve the table on whichever one owns the roll. +pub(crate) fn effect_chain_has_terminal_roll_die(chain: &EffectChainIr) -> bool { + chain + .clauses + .last() + .is_some_and(|clause| matches!(clause.parsed.effect, Effect::RollDie { .. })) +} + /// The body of a trigger. Whole-body recognizers retain their typed payloads /// here so trigger lowering owns all root-level transforms. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 165fd795c1..5bca30cb3a 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -4430,6 +4430,51 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ assert_eq!(sub.mode_abilities.len(), 2, "both modes must survive"); } + /// CR 706.3b: result-table rows belong to the mandatory printed die-roll + /// parent, even when its CR 603.12 reflexive body is a modal marker. + #[test] + fn mandatory_roll_die_parent_retains_its_result_table() { + let parsed = parse_oracle_text( + "When this creature enters, roll a d20. When you do, choose one —\n• Draw a card.\n• You gain 2 life.\n1—10 | Draw a card.\n11—20 | You gain 2 life.", + "Roll Parent Probe", + &[], + &["Creature".to_string()], + &[], + ); + let execute = parsed + .triggers + .first() + .and_then(|trigger| trigger.execute.as_ref()) + .expect("the enters trigger must carry its mandatory parent"); + let Effect::RollDie { results, .. } = execute.effect.as_ref() else { + panic!( + "the printed roll must remain the parent effect, got {:?}", + execute.effect + ); + }; + assert_eq!( + results.len(), + 2, + "the parent roll must retain both immediately following result rows" + ); + assert_eq!( + results + .iter() + .map(|branch| (branch.min, branch.max)) + .collect::>(), + vec![(1, 10), (11, 20)], + "the result ranges must remain attached to the printed parent roll" + ); + assert_eq!( + execute + .sub_ability + .as_ref() + .and_then(|sub| sub.condition.clone()), + Some(AbilityCondition::WhenYouDo), + "the mode list remains the parent roll's reflexive body" + ); + } + #[test] fn caesar_lowers_to_reflexive_gated_modal() { // CR 603.12 + CR 700.2b: Caesar's attack trigger must lower to an diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 9b03bb4773..dfbe5c2bac 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -18,8 +18,8 @@ use super::oracle_ir::context::{ParseContext, TriggerConditionScope}; use super::oracle_ir::doc::PrintedTriggerIndex; use super::oracle_ir::effect_chain::{DieResultBranchIr, EffectChainIr}; use super::oracle_ir::trigger::{ - FirstTimeLimit, ReflexiveParent, ReflexiveParentIr, TriggerBody, TriggerIr, TriggerModifiers, - TriggerNodeIr, + effect_chain_has_terminal_roll_die, FirstTimeLimit, ReflexiveParent, ReflexiveParentIr, + TriggerBody, TriggerIr, TriggerModifiers, TriggerNodeIr, }; use super::oracle_modal::try_parse_inline_modal_ir; use super::oracle_nom::condition::parse_elided_subject_state_condition; @@ -1766,8 +1766,18 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { &ir.die_results, ))), Some(TriggerBody::Reflexive(reflexive)) => { - let mut reflexive_ability = - lower_trigger_effect_chain(&reflexive.effect_chain, modifiers, &ir.die_results); + let reflexive_owns_die_results = + effect_chain_has_terminal_roll_die(&reflexive.effect_chain); + let (reflexive_die_results, parent_die_results) = if reflexive_owns_die_results { + (ir.die_results.as_slice(), &[]) + } else { + (&[], ir.die_results.as_slice()) + }; + let mut reflexive_ability = lower_trigger_effect_chain( + &reflexive.effect_chain, + modifiers, + reflexive_die_results, + ); reflexive_ability.condition = Some(AbilityCondition::WhenYouDo); if let Some(modal) = &reflexive.modal { @@ -1790,7 +1800,9 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { payment_chain, } => { let mut pay_ability = match payment_chain { - Some(chain) => lower_trigger_effect_chain(chain, modifiers, &[]), + Some(chain) => { + lower_trigger_effect_chain(chain, modifiers, parent_die_results) + } None => AbilityDefinition::new( AbilityKind::Spell, Effect::PayCost { @@ -1807,7 +1819,7 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { // printed instruction, not an offer — it carries no `optional` // flag and no decline prompt. ReflexiveParent::Mandatory { instruction } => { - lower_trigger_effect_chain(instruction, modifiers, &[]) + lower_trigger_effect_chain(instruction, modifiers, parent_die_results) } }; parent_ability.sub_ability = Some(Box::new(reflexive_ability)); From 65c34e22d0a9ef819be39fcac6edccb068a7d006 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 05:04:18 -0700 Subject: [PATCH 5/7] fix(PR-7529): coerce die-result routing slices Co-authored-by: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> --- crates/engine/src/parser/oracle_trigger.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index dfbe5c2bac..c4ea8b9985 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -1768,7 +1768,10 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { Some(TriggerBody::Reflexive(reflexive)) => { let reflexive_owns_die_results = effect_chain_has_terminal_roll_die(&reflexive.effect_chain); - let (reflexive_die_results, parent_die_results) = if reflexive_owns_die_results { + let (reflexive_die_results, parent_die_results): ( + &[DieResultBranchIr], + &[DieResultBranchIr], + ) = if reflexive_owns_die_results { (ir.die_results.as_slice(), &[]) } else { (&[], ir.die_results.as_slice()) From 1d40dd39e20daa8222783ac901cbf1c437c35084 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 06:10:12 -0700 Subject: [PATCH 6/7] fix(PR-7529): parse mandatory reflexive parent chain --- crates/engine/src/parser/oracle_modal.rs | 32 +++++++++++++++--------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 5bca30cb3a..49ca00aa5a 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -987,39 +987,46 @@ fn split_triggered_modal_header(line: &str) -> Option<(String, String)> { /// * `MayPay` — `trigger_line` is reduced to the bare trigger condition and the /// instruction text travels separately, because that optional instruction is /// lowered by the resolution-time path. -/// * `Mandatory` — `trigger_line` is returned WHOLE, instruction and connector -/// included. The trigger parser already lowers exactly this shape correctly -/// for every non-modal reflexive; lowering then hangs the modal off that -/// chain rather than replacing it. +/// * `Mandatory` — the terminal connector is removed and the printed +/// instruction remains in `trigger_line`. The trigger parser lowers that +/// instruction as the parent chain; lowering then hangs the modal off it. /// * `None` — no connector: a plain triggered modal (Pip-Boy 3000). fn classify_reflexive_modal_parent(trigger_line: String) -> (String, Option) { if let Some((trigger, cost)) = split_reflexive_optional_cost(&trigger_line) { return (trigger, Some(ReflexiveModalParent::MayPay(cost))); } - if ends_in_reflexive_connector(&trigger_line) { - return (trigger_line, Some(ReflexiveModalParent::Mandatory)); + if let Some(instruction) = strip_terminal_reflexive_connector(&trigger_line) { + return ( + instruction.to_string(), + Some(ReflexiveModalParent::Mandatory), + ); } (trigger_line, None) } -/// CR 603.12: whether everything after `trigger_line`'s final sentence break is -/// the bare reflexive connector. +/// CR 603.12: remove a bare reflexive connector after `trigger_line`'s final +/// sentence break, leaving the parent instruction for ordinary trigger parsing. /// /// `split_triggered_modal_header` has already taken the mode list away, so the /// connector is the entire tail — there is no reflexive body left here to /// consume. Scanning to the LAST break rather than the first is what keeps a /// multi-sentence instruction ("Scry 1. When you do") from being read as a /// connector that is not there. -fn ends_in_reflexive_connector(trigger_line: &str) -> bool { +fn strip_terminal_reflexive_connector(trigger_line: &str) -> Option<&str> { let lower = trigger_line.to_lowercase(); let mut tail = lower.as_str(); + let mut connector_start = None; while let Ok((after, _)) = terminated(take_until::<_, _, OracleError<'_>>(". "), tag(". ")).parse(tail) { + connector_start = Some(lower.len() - after.len() - 2); tail = after; } let tail = tail.trim().trim_end_matches(',').trim(); - nom_condition::match_when_you_do(tail).is_ok_and(|(rest, ())| rest.is_empty()) + if !nom_condition::match_when_you_do(tail).is_ok_and(|(rest, ())| rest.is_empty()) { + return None; + } + trigger_line.get(..connector_start?).map(str::trim_end) } /// CR 603.12 + CR 700.2b: Recognize a reflexive optional-instruction trigger @@ -4330,11 +4337,12 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ "Whenever you attack", ), // Connector, NO marker: Cemetery Desecrator. The instruction stays - // in `trigger_line` for the ordinary trigger parser to lower. + // in `trigger_line`, but its connector is removed before the + // ordinary trigger parser lowers the mandatory parent chain. ( "When this creature enters or dies, exile another card from a graveyard. When you do", Some(ReflexiveModalParent::Mandatory), - "When this creature enters or dies, exile another card from a graveyard. When you do", + "When this creature enters or dies, exile another card from a graveyard", ), // Neither: Pip-Boy 3000 stays a plain triggered modal. ( From ae9ba632449efdbe440f10b5dc97553ed5178f91 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 07:11:07 -0700 Subject: [PATCH 7/7] fix(PR-7529): constrain mandatory reflexive classifier --- crates/engine/src/parser/oracle_ir/ast.rs | 2 +- crates/engine/src/parser/oracle_ir/trigger.rs | 2 +- crates/engine/src/parser/oracle_modal.rs | 20 +++++++++++++++---- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index aea88a780c..de77f5b30c 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -2006,7 +2006,7 @@ pub(crate) enum ReflexiveModalParent { /// `trigger_line` is reduced to the bare trigger condition alongside it. MayPay(String), /// `"…, . When you do, choose …"` — a mandatory instruction - /// (Cemetery Desecrator, Dialogue Tree). No text is carried: the + /// (Cemetery Desecrator). No text is carried: the /// instruction stays in `trigger_line`, where the ordinary trigger parser /// lowers it as it already does for every non-modal reflexive (Bone /// Rattler, Diregraf Horde). Lowering then attaches the modal as that diff --git a/crates/engine/src/parser/oracle_ir/trigger.rs b/crates/engine/src/parser/oracle_ir/trigger.rs index dcbe1da1a4..27e6032362 100644 --- a/crates/engine/src/parser/oracle_ir/trigger.rs +++ b/crates/engine/src/parser/oracle_ir/trigger.rs @@ -185,7 +185,7 @@ pub(crate) enum ReflexiveParent { payment_chain: Option, }, /// `". When you do, …"` — the instruction is not an offer, it - /// simply happens (Cemetery Desecrator, Dialogue Tree). The trigger parser + /// simply happens (Cemetery Desecrator). The trigger parser /// already lowered the printed instruction into this chain, so lowering /// reuses it instead of re-parsing the same words a second time. Mandatory { instruction: EffectChainIr }, diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 49ca00aa5a..b4ed5e1e05 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -17,6 +17,7 @@ use crate::types::replacements::ReplacementEvent; use crate::types::triggers::TriggerMode; use super::oracle::{find_activated_colon, strip_activated_constraints}; +use super::oracle_classifier::has_trigger_prefix; use super::oracle_cost::parse_oracle_cost; #[cfg(test)] use super::oracle_effect::lower_ability_ir; @@ -983,6 +984,8 @@ fn split_triggered_modal_header(line: &str) -> Option<(String, String)> { /// marker — as this dispatch did — silently answered /// "no reflexive here" for every mandatory parent, and the mode list then /// attached straight to the trigger with the printed instruction discarded. +/// A mandatory parent must still retain the shared trigger-prefix shape; +/// non-triggered effects keep their original line and existing route. /// /// * `MayPay` — `trigger_line` is reduced to the bare trigger condition and the /// instruction text travels separately, because that optional instruction is @@ -995,7 +998,9 @@ fn classify_reflexive_modal_parent(trigger_line: String) -> (String, Option (String, Option Option<&str> { let lower = trigger_line.to_lowercase(); let mut tail = lower.as_str(); @@ -4344,6 +4348,14 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ Some(ReflexiveModalParent::Mandatory), "When this creature enters or dies, exile another card from a graveyard", ), + // Dialogue Tree is a sorcery, not a triggered parent. Its terminal + // connector must stay intact so this targeted reflexive repair does + // not alter the non-triggered modal route. + ( + "Scry 1. When you do", + None, + "Scry 1. When you do", + ), // Neither: Pip-Boy 3000 stays a plain triggered modal. ( "Whenever equipped creature attacks",