Skip to content
8 changes: 7 additions & 1 deletion crates/engine/src/parser/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)));
}
Expand Down
39 changes: 30 additions & 9 deletions crates/engine/src/parser/oracle_ir/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,28 @@ fn normalize_play_from_exile_duration(duration: Duration) -> Duration {

// --- Modal types (moved from oracle_modal.rs) ---

/// CR 603.12: The printed instruction a triggered modal's reflexive
/// connector rides on — `"<trigger>, <instruction>. When you do, choose …"`.
///
/// 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 <instruction>. 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),
/// `"…, <instruction>. When you do, choose …"` — a mandatory instruction
/// (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
/// chain's `WhenYouDo` sub instead of replacing it.
Mandatory,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) enum OracleBlockAst {
ActivatedModal {
Expand All @@ -2008,15 +2030,14 @@ pub(crate) enum OracleBlockAst {
trigger_line: String,
header: ModalHeaderAst,
modes: Vec<ModeAst>,
/// 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<String>,
/// 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<ReflexiveModalParent>,
},
/// CR 614.12c + CR 607.2d: "As [this permanent] enters, choose <A> or
/// <B>. \n • <A> — <linked ability>. \n • <B> — <linked ability>." The
Expand Down
83 changes: 68 additions & 15 deletions crates/engine/src/parser/oracle_ir/trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,30 +95,55 @@ 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::ReflexivePayment(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)]
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<ReflexivePaymentIr>),
/// CR 603.12: A printed parent instruction and the reflexive
/// effect that follows when its event occurred.
Reflexive(Box<ReflexiveParentIr>),
/// 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.
Expand All @@ -130,14 +155,42 @@ pub(crate) enum TriggerBody {
Pile(Box<PileIr>),
}

/// 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 <instruction>. 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 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<EffectChainIr>,
/// CR 700.2b: modal metadata when the reflexive body is a mode choice.
pub(crate) modal: Option<ModalIr>,
}

/// CR 603.12: the two printed forms a reflexive parent can take.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) enum ReflexiveParent {
/// `"you may <instruction>. When you do, …"` — a resolution-time offer the
/// controller may decline (Caesar, Legion's Emperor). `payment_chain`
/// carries the printed instruction as an effect chain when one parsed; otherwise
/// lowering synthesizes an `Effect::PayCost` from `cost`.
MayPay {
cost: AbilityCost,
payment_chain: Option<EffectChainIr>,
},
/// `"<instruction>. When you do, …"` — the instruction is not an offer, it
/// 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 },
}

/// CR 700.2: Typed inline-modal trigger body.
///
/// The root marker is an ordinary effect chain so trigger lowering applies the
Expand Down
Loading
Loading